Skip to main content

arora_web/
lib.rs

1//! Run an Arora device in the browser.
2//!
3//! The centerpiece is [`AroraWeb`] — exported to JavaScript as `AroraRuntime`
4//! — the `wasm_bindgen` surface over an [`arora::Arora`]: a synchronous
5//! [`step`](AroraWeb::step), a self-pacing [`run`](AroraWeb::run), in-process
6//! [`call`](AroraWeb::call) dispatch, and the [`store_json`] accessors that
7//! move store values across the JS boundary as JSON in the Arora
8//! [`Value`] vocabulary. Its JS constructor (and [`AroraWebBuilder`], JS
9//! `AroraRuntimeBuilder`) builds the self-contained demo device on the
10//! runtime's in-process defaults; a downstream device composes its own
11//! [`arora::Arora`] with [`arora::AroraBuilder`] — the HAL, bridge, and store
12//! seams are trait objects that cannot cross the JS boundary — and wraps it
13//! with `AroraWeb::from`, reusing this whole JS surface.
14//!
15//! It also carries a lower-level surface: [`Engine`] and [`BehaviorTreeRunner`]
16//! load guest modules (header JSON + executable bytes) and run behavior trees
17//! directly on the engine, hosting modules via the browser's native
18//! `WebAssembly` runtime — see `arora_engine::executor::browser`.
19//!
20//! This crate only carries non-trivial content when built for `wasm32-*`
21//! targets. On the host it is an empty shim (deps are gated to `wasm32`) so it
22//! can sit in the workspace and be verified by `cargo package` on publish
23//! without pulling wasm-only deps into a host link.
24
25#![cfg(target_arch = "wasm32")]
26
27use std::cell::RefCell;
28use std::collections::HashMap;
29use std::pin::Pin;
30use std::rc::Rc;
31
32use arora_engine::{
33    call::{CallBridge, Callable, CallableId},
34    engine::EngineBuilder,
35    executor::browser::{BrowserExecutor, SharedLoaderRc},
36    load::load_module_from_parts,
37};
38use arora_types::module::low::Header;
39use arora_types::{
40    call::Call,
41    value::{Enumeration, StructureField, StructureWithoutId, Value},
42};
43use uuid::Uuid;
44use wasm_bindgen::prelude::*;
45
46/// Route Rust panics to the browser console. Called from every entry point
47/// (the [`AroraWeb`] constructors, `Engine::new`, `BehaviorTreeRunner::new`)
48/// rather than from a `#[wasm_bindgen(start)]`: a reusable library must not
49/// claim the module `start`, or every downstream cdylib that also defines one
50/// collides on the `_start` symbol at link time. `set_once` makes repeat calls
51/// free.
52fn install_panic_hook() {
53    console_error_panic_hook::set_once();
54}
55
56// =============================================================================
57// The browser device
58// =============================================================================
59
60use arora::{Arora, AroraBuilder, Caller, LocalCaller, MethodSignature, TaskId};
61use arora_bridge::client::{self, KeyInfo};
62use arora_types::call::CallError;
63use arora_types::data::{DataStore, Key, StateChange, Subscription};
64use futures::future::Either;
65use futures::FutureExt;
66use std::cell::Cell;
67use std::future::Future;
68use std::task::Poll;
69use std::time::Duration;
70use tokio::sync::watch;
71
72/// Hand `future` to JavaScript as a Promise, having polled it once already: a
73/// future that is already done settles the promise at once, and one that is
74/// not is polled on from wasm-bindgen-futures' queue.
75fn promise(future: impl Future<Output = Result<JsValue, JsValue>> + 'static) -> js_sys::Promise {
76    let mut future = Box::pin(future);
77    let mut cx = std::task::Context::from_waker(futures::task::noop_waker_ref());
78    match future.as_mut().poll(&mut cx) {
79        std::task::Poll::Ready(Ok(value)) => js_sys::Promise::resolve(&value),
80        std::task::Poll::Ready(Err(error)) => js_sys::Promise::reject(&error),
81        std::task::Poll::Pending => wasm_bindgen_futures::future_to_promise(future),
82    }
83}
84
85/// Serialize to a plain JS value — objects as objects, not `Map`s — the form
86/// every structured answer of this surface takes.
87fn to_js(value: &impl serde::Serialize) -> Result<JsValue, JsValue> {
88    value
89        .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
90        .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
91}
92
93/// Serialize an Arora [`Value`] to the plain JS form of its JSON, e.g.
94/// `{"f32": 0.75}`.
95fn value_to_js(value: &Value) -> Result<JsValue, JsValue> {
96    let json = serde_json::to_value(value)
97        .map_err(|e| JsValue::from_str(&format!("serialize value failed: {e}")))?;
98    to_js(&json)
99}
100
101/// A run handle as a client reads it — the plain object `invoke` of a
102/// task-shaped method and `spawn` answer with: `arora_bridge::client::Run`.
103fn run_to_js(handle: &arora::TaskHandle) -> Result<JsValue, JsValue> {
104    let spawned = arora_behavior::interpreter_module::encode_spawn_result(handle);
105    let run = client::run_of(&spawned).map_err(|e| JsValue::from_str(&e))?;
106    to_js(&run)
107}
108
109/// Value↔JSON accessors over a device's [`DataStore`] and change
110/// [`Subscription`] — the store surface every browser device shares. Values
111/// cross the JS boundary as JSON in the Arora [`Value`] vocabulary, e.g.
112/// `{"f32": 0.75}`. [`AroraWeb`] exposes them as methods; a downstream device
113/// wrapping its own store reaches them here directly.
114pub mod store_json {
115    use super::*;
116
117    /// Write one key into the store. `value_json` is an Arora [`Value`] as
118    /// JSON, e.g. `{"f32": 0.75}`.
119    pub fn set_value(store: &dyn DataStore, path: &str, value_json: &str) -> Result<(), JsValue> {
120        let value: Value = serde_json::from_str(value_json)
121            .map_err(|e| JsValue::from_str(&format!("invalid value json for {path}: {e}")))?;
122        store
123            .write(StateChange::set(path, value))
124            .map_err(|e| JsValue::from_str(&format!("write {path} failed: {e}")))
125    }
126
127    /// Write several keys at once, as one store change. `values_json` is a JSON
128    /// object mapping each key path to an Arora [`Value`], e.g.
129    /// `{"sensor/x": {"f32": 0.75}}`.
130    pub fn write_values(store: &dyn DataStore, values_json: &str) -> Result<(), JsValue> {
131        let map: serde_json::Map<String, serde_json::Value> = serde_json::from_str(values_json)
132            .map_err(|e| JsValue::from_str(&format!("invalid values json: {e}")))?;
133        let mut change = StateChange::new();
134        for (path, raw) in map {
135            let value: Value = serde_json::from_value(raw)
136                .map_err(|e| JsValue::from_str(&format!("invalid value for {path}: {e}")))?;
137            change.set.insert(Key::new(path), Some(value));
138        }
139        store
140            .write(change)
141            .map_err(|e| JsValue::from_str(&format!("write failed: {e}")))
142    }
143
144    /// Read keys from the store. `paths` is a JS `string[]`; the result is a JS
145    /// object mapping each path to its Arora [`Value`] (or `null` if absent).
146    pub fn read_values(store: &dyn DataStore, paths: JsValue) -> Result<JsValue, JsValue> {
147        let paths: Vec<String> = serde_wasm_bindgen::from_value(paths)
148            .map_err(|e| JsValue::from_str(&format!("paths must be a string[]: {e}")))?;
149        let keys: Vec<Key> = paths.iter().map(Key::new).collect();
150        let values = store.read(&keys);
151        let mut out = serde_json::Map::with_capacity(paths.len());
152        for (path, value) in paths.into_iter().zip(values) {
153            out.insert(path, value_to_json(value)?);
154        }
155        to_js_object(out)
156    }
157
158    /// A snapshot of every key currently in the store, as a JS object mapping
159    /// path → Arora [`Value`].
160    pub fn snapshot(store: &dyn DataStore) -> Result<JsValue, JsValue> {
161        let state = store.snapshot();
162        let mut out = serde_json::Map::with_capacity(state.storage.len());
163        for (key, value) in state.storage {
164            out.insert(key.path, value_to_json(value)?);
165        }
166        to_js_object(out)
167    }
168
169    /// Drain the changes the subscription holds, as a JS object mapping each
170    /// changed path to its latest Arora [`Value`] (or `null` for a cleared
171    /// key). Poll-based counterpart to a push subscription:
172    /// [`DataStore::subscribe`] delivers over a std channel JavaScript cannot
173    /// await, so the channel holds every change until it is drained here. A
174    /// subscription opens on the store's whole current state, so the first
175    /// drain returns the full picture.
176    ///
177    /// [`AroraWeb`] does not leave its subscription's channel to grow between
178    /// drains: it folds it into the latest value per key on every step.
179    pub fn drain_changes(changes: &Subscription) -> Result<JsValue, JsValue> {
180        let mut latest = HashMap::new();
181        while let Some(change) = changes.try_recv() {
182            fold_change(&mut latest, change);
183        }
184        latest_to_js(latest.drain())
185    }
186
187    /// Fold `change` into `latest`, the latest value per key: a key `change`
188    /// sets holds its new value, a key it unsets holds `None`.
189    pub(crate) fn fold_change(latest: &mut HashMap<Key, Option<Value>>, change: StateChange) {
190        latest.extend(change.set);
191        latest.extend(change.unset.into_iter().map(|key| (key, None)));
192    }
193
194    /// The latest value per key as a JS object mapping path → Arora [`Value`]
195    /// (`null` for a cleared key).
196    pub(crate) fn latest_to_js(
197        latest: impl Iterator<Item = (Key, Option<Value>)>,
198    ) -> Result<JsValue, JsValue> {
199        let mut out = serde_json::Map::new();
200        for (key, value) in latest {
201            out.insert(key.path, value_to_json(value)?);
202        }
203        to_js_object(out)
204    }
205
206    /// Serialize a path-keyed JSON map to a **plain JS object**.
207    /// serde-wasm-bindgen's default representation for maps is a JS `Map`, but
208    /// these accessors promise plain objects (`{"sensor/x": {"f32": 0.5}}`),
209    /// so serialize JSON-compatibly.
210    fn to_js_object(out: serde_json::Map<String, serde_json::Value>) -> Result<JsValue, JsValue> {
211        use serde::Serialize;
212        serde_json::Value::Object(out)
213            .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
214            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
215    }
216
217    /// Serialize an optional Arora value to JSON (`null` when absent).
218    fn value_to_json(value: Option<Value>) -> Result<serde_json::Value, JsValue> {
219        match value {
220            Some(v) => serde_json::to_value(v)
221                .map_err(|e| JsValue::from_str(&format!("serialize value failed: {e}"))),
222            None => Ok(serde_json::Value::Null),
223        }
224    }
225}
226
227/// The store changes a page has not drained yet, as the latest value per key.
228///
229/// The subscription's channel takes a copy of every change the store applies;
230/// a device changing a few hundred keys per step at 100 steps a second fills
231/// it with tens of thousands of entries a second, all held until a drain.
232/// Folded on every step and on every write the page makes, the channel holds
233/// no more than one step's changes, and `latest` one entry per key changed
234/// since the last drain: what a page has not drained grows with the keys that
235/// change, not with the steps it goes without draining. A drain hands over the
236/// net change since the previous one — a key set twice reads its second value,
237/// a key set then unset reads cleared — which is what a page shows.
238struct Undrained {
239    subscription: Subscription,
240    latest: HashMap<Key, Option<Value>>,
241}
242
243impl Undrained {
244    fn new(subscription: Subscription) -> Self {
245        Self {
246            subscription,
247            latest: HashMap::new(),
248        }
249    }
250
251    /// Move what the subscription's channel holds into `latest`.
252    fn fold(&mut self) {
253        while let Some(change) = self.subscription.try_recv() {
254            store_json::fold_change(&mut self.latest, change);
255        }
256    }
257
258    /// Everything not drained yet, as a path → Value JS object; `latest`
259    /// keeps its capacity for the changes that follow.
260    fn drain(&mut self) -> Result<JsValue, JsValue> {
261        self.fold();
262        store_json::latest_to_js(self.latest.drain())
263    }
264}
265
266#[wasm_bindgen]
267extern "C" {
268    /// The global `queueMicrotask`, in a page, a worker and Node alike.
269    #[wasm_bindgen(js_name = queueMicrotask)]
270    fn queue_microtask(callback: &js_sys::Function);
271
272    /// The global `setTimeout`, in a page, a worker and Node alike.
273    #[wasm_bindgen(js_name = setTimeout)]
274    fn set_timeout(callback: &js_sys::Function, ms: i32);
275
276    #[wasm_bindgen(js_namespace = console, js_name = error)]
277    fn console_error(message: &str);
278}
279
280/// What the device's steps did that a waiting operation acts on, sent to every
281/// operation awaiting its reply.
282#[derive(Clone, Debug)]
283enum Stepped {
284    /// Nothing an operation waits on has happened yet.
285    Idle,
286    /// A step failed with this message, so an operation it left unanswered
287    /// rejects with it rather than waiting for a step that may fail the same
288    /// way.
289    Failed(String),
290    /// [`run`](AroraWeb::run) gave the device back. An operation its steps
291    /// left unanswered asks for a step at a zero dt, since no run step
292    /// will apply it now.
293    Parked,
294}
295
296/// Steps the device at a zero dt for what the page asked of it while no
297/// [`run`](AroraWeb::run) is ongoing.
298///
299/// The requests of one turn — the page's operations and its writes to the
300/// store — share one step, taken in the next microtask. It applies everything
301/// queued on the device's inbound channel (the page's operations in the order
302/// it issued them, and what bridges delivered), ticks the behavior with no
303/// time elapsed, and writes the HAL and the bridges what changed, the page's
304/// writes with it. A step that runs first takes the place of the requested
305/// one: a page's own [`step`](AroraWeb::step) clears the request, and while
306/// `run()` owns the device its next step applies the queue, so the request
307/// lapses.
308///
309/// A request made from inside a step cannot nest one: its step follows the
310/// current one as a task (`setTimeout`), not a microtask, so a callback that
311/// asks for one on every tick paces a chain of steps on the event loop
312/// instead of starving it.
313#[derive(Clone)]
314struct StepScheduler {
315    device: Rc<RefCell<Option<Arora>>>,
316    changes: Rc<RefCell<Undrained>>,
317    // Whether a step at a zero dt is due; a microtask or a task is queued
318    // whenever it is.
319    due: Rc<Cell<bool>>,
320    stepped: Rc<watch::Sender<Stepped>>,
321}
322
323impl StepScheduler {
324    fn new(device: Arora, changes: Rc<RefCell<Undrained>>) -> Self {
325        Self {
326            device: Rc::new(RefCell::new(Some(device))),
327            changes,
328            due: Rc::new(Cell::new(false)),
329            stepped: Rc::new(watch::channel(Stepped::Idle).0),
330        }
331    }
332
333    /// Whether a step holds the device: a page's own, or one at a zero dt.
334    fn mid_step(&self) -> bool {
335        self.device.try_borrow_mut().is_err()
336    }
337
338    /// Ask for a step at a zero dt: in the next microtask, or after the
339    /// current step when asked from inside one. Requests made before it runs
340    /// share it.
341    fn request(&self) {
342        if self.due.replace(true) {
343            return;
344        }
345        let scheduler = self.clone();
346        let callback = Closure::once_into_js(move || scheduler.step_if_due());
347        if self.mid_step() {
348            set_timeout(callback.unchecked_ref(), 0);
349        } else {
350            queue_microtask(callback.unchecked_ref());
351        }
352    }
353
354    /// A step is about to apply the queue: the request is met by it.
355    fn met(&self) {
356        self.due.set(false);
357    }
358
359    /// A step failed: what it left unanswered rejects with `message`.
360    fn failed(&self, message: String) {
361        self.stepped.send_replace(Stepped::Failed(message));
362    }
363
364    fn step_if_due(&self) {
365        if !self.due.replace(false) {
366            return;
367        }
368        let Ok(mut parked) = self.device.try_borrow_mut() else {
369            // A microtask or a task runs between steps, never inside one;
370            // ask again after this one all the same.
371            self.request();
372            return;
373        };
374        // While `run()` owns the device, the run's next step applies the
375        // queue.
376        let Some(device) = parked.as_mut() else {
377            return;
378        };
379        let stepped = device.step(Duration::ZERO);
380        drop(parked);
381        self.changes.borrow_mut().fold();
382        if let Err(e) = stepped {
383            let message = e.to_string();
384            // A page's write has no promise to reject: the console says it.
385            console_error(&format!("arora-web: a step at a zero dt failed: {message}"));
386            self.failed(message);
387        }
388    }
389}
390
391/// The browser Arora device, exported to JavaScript as `AroraRuntime`.
392///
393/// The JS constructor builds the self-contained demo device — the runtime's
394/// in-process defaults: fake hardware, no bridge, a private store, an empty
395/// behavior — and [`AroraWebBuilder`] (JS `AroraRuntimeBuilder`) additionally
396/// loads guest modules. A downstream device composes its own [`Arora`] with
397/// [`arora::AroraBuilder`] and wraps it with `AroraWeb::from`, reusing this
398/// whole JS surface.
399///
400/// Drive it by calling [`step`](Self::step) from your own clock (e.g.
401/// `requestAnimationFrame`) or by awaiting [`run`](Self::run), which hands the
402/// device to [`Arora::run`] until [`stop`](AroraWeb::stop) reclaims it.
403/// Either way the rest of the surface stays live: `setValue`/`readValues`/
404/// `snapshot` work on a sibling handle of the store, `drainChanges` on its
405/// subscription — folded into the latest value per key on every step, so what
406/// a page has not drained is one entry per changed key however many steps it
407/// goes without draining — and the client operations are the ones a remote
408/// client has over a bridge.
409///
410/// The operations that change the device — `call`, `invoke`, `spawn`, `halt` —
411/// are queued on its in-process [`LocalCaller`] before the method returns,
412/// and settle on the reply of the step that applies them:
413/// - while no `run()` is ongoing, a step at a zero dt the operation asks for:
414///   the operations issued in one turn share one such step, applied in the
415///   order they were issued, so a page that never steps the device has them
416///   applied all the same. A page that steps in the same turn applies them
417///   with its own step, and none at a zero dt follows;
418/// - while `run()` owns the device, the run's next step: the cadence is the
419///   run's alone.
420///
421/// A step at a zero dt is an ordinary step with no time elapsed: it publishes
422/// `arora/dt` as zero and ticks the behavior once, so what runs on time holds
423/// while a node that counts ticks advances one. A step that fails rejects each
424/// operation it left unanswered with its error. That operation is not
425/// withdrawn: if the step failed before applying the queue, a later step still
426/// applies it.
427///
428/// A page's writes to the store — `setValue`, `writeValues` — hold in the store
429/// when the method returns, and reach the HAL and the bridges by the same
430/// steps: a step at a zero dt they share with the turn's operations while no
431/// `run()` is ongoing, the run's next step while one is.
432///
433/// The operations that only read — `listKeys`, `describeMethods` — answer
434/// from the device's current state, with no step, in both modes: the keys
435/// from the store's sibling handle, the methods from the method index read
436/// when the device was wrapped (it changes only through `Arora`'s module
437/// methods, which this wrapper does not call). A read does not see an
438/// operation still queued, so a page awaits that operation's promise first.
439///
440/// A call into this surface from a callback the step itself invokes is an
441/// ordinary call: a step cannot nest, so an operation it issues is queued, and
442/// applied by a step at a zero dt taken as a task once the current step has
443/// ended (or by the run's next step). A write it makes is carried out by the
444/// current step, or by the next one if the current step has already flushed.
445/// A page should not rely on such calls: what a step needs done belongs in a
446/// behavior, where it is part of the step.
447///
448/// Every method is an ordinary Rust method too, for a Rust crate that wraps
449/// this device in its own `wasm_bindgen` type; [`caller`](Self::caller) hands
450/// such a crate the typed operations, and [`complete`](Self::complete) has
451/// them settle as this surface's do.
452#[wasm_bindgen(js_name = AroraRuntime)]
453pub struct AroraWeb {
454    // The device and the zero-dt steps taken for the page. The device parks
455    // in the scheduler between steps; `run` takes it out and the run
456    // future puts it back.
457    steps: StepScheduler,
458    // Ends the current `run` loop at its next step boundary; `None` while no
459    // loop runs (the sender is consumed by `stop`).
460    stop: RefCell<Option<futures::channel::oneshot::Sender<()>>>,
461    caller: LocalCaller,
462    store: Box<dyn DataStore>,
463    // The device's method index, read when it was wrapped: `describeMethods`
464    // and `invoke` answer from it without a step.
465    methods: Vec<MethodSignature>,
466    // Shared with the run future, which folds the subscription after every
467    // step it takes.
468    changes: Rc<RefCell<Undrained>>,
469    // The device's standing-error watch, taken before `run` can own the
470    // device, so the reading stays available while it self-paces.
471    behavior_error: watch::Receiver<Option<String>>,
472    // The awaitable end of the same watch: taken out for the duration of one
473    // `behaviorErrorChanged` await, so sequential awaits share one seen
474    // cursor and no change is missed between them.
475    behavior_error_changes: Rc<RefCell<Option<watch::Receiver<Option<String>>>>>,
476}
477
478impl From<Arora> for AroraWeb {
479    /// Wrap a composed device: keep its in-process [`LocalCaller`], a sibling
480    /// handle of its store, its method index, and a subscription (opening on
481    /// the whole current state), then expose it all to JavaScript.
482    fn from(arora: Arora) -> Self {
483        install_panic_hook();
484        let caller = arora.caller();
485        let store = arora.store().clone_box();
486        let methods = arora.describe_methods(None);
487        let changes = Rc::new(RefCell::new(Undrained::new(store.subscribe())));
488        let behavior_error = arora.behavior_error();
489        let behavior_error_changes = Rc::new(RefCell::new(Some(arora.behavior_error())));
490        Self {
491            steps: StepScheduler::new(arora, changes.clone()),
492            stop: RefCell::new(None),
493            caller,
494            store,
495            methods,
496            changes,
497            behavior_error,
498            behavior_error_changes,
499        }
500    }
501}
502
503#[wasm_bindgen(js_class = "AroraRuntime")]
504impl AroraWeb {
505    /// Build the demo device on the runtime's in-process defaults: fake HAL,
506    /// no bridge, a private store, an empty (idle) behavior interpreter.
507    #[wasm_bindgen(constructor)]
508    pub fn new() -> Result<AroraWeb, JsValue> {
509        Ok(AroraWeb::from(Arora::builder().build().map_err(|e| {
510            JsValue::from_str(&format!("arora build failed: {e:?}"))
511        })?))
512    }
513
514    /// Advance the device one step. `dt_ms` is the milliseconds elapsed since
515    /// the previous step — a plain JS number, exactly what a
516    /// `requestAnimationFrame` timestamp delta gives. This is the only place
517    /// the rAF millisecond unit is converted to the core `Duration` dt.
518    /// It applies every operation queued so far, so none of them takes a step
519    /// at a zero dt. Unavailable once [`run`](Self::run) has taken the device,
520    /// and from a callback the step itself invokes.
521    pub fn step(&self, dt_ms: f64) -> Result<(), JsValue> {
522        let mut parked = self
523            .steps
524            .device
525            .try_borrow_mut()
526            .map_err(|_| JsValue::from_str("the device is mid-step; call between steps"))?;
527        let device = parked
528            .as_mut()
529            .ok_or_else(|| JsValue::from_str("run() drives the device; step() is unavailable"))?;
530        self.steps.met();
531        let stepped = device.step(Duration::from_secs_f64(dt_ms / 1_000.0));
532        drop(parked);
533        self.changes.borrow_mut().fold();
534        stepped.map_err(|e| {
535            // The operations this step left unanswered reject with its error
536            // rather than wait for a step that may fail the same way.
537            self.steps.failed(e.to_string());
538            JsValue::from_str(&format!("step failed: {e}"))
539        })
540    }
541
542    /// Hand the device to [`Arora::run`]: a self-paced loop at `period_ms`
543    /// (default: the runtime's ~100 Hz) that owns the device until stepping
544    /// fails — the promise rejects — or [`stop`](Self::stop) reclaims it — the
545    /// promise resolves and `step()` works again. While it runs the rest of
546    /// the surface keeps working, and the operations that change the device
547    /// apply at the run's next step, adding no step of their own.
548    ///
549    /// `run` awaits between steps, so it shares its thread; see
550    /// [`Arora::run`] for where a cadence survives in a browser (main thread
551    /// vs dedicated worker).
552    pub fn run(&self, period_ms: Option<f64>) -> js_sys::Promise {
553        let taken = match self.steps.device.try_borrow_mut() {
554            Ok(mut parked) => parked.take(),
555            Err(_) => {
556                return js_sys::Promise::reject(&JsValue::from_str(
557                    "the device is mid-step; call between steps",
558                ))
559            }
560        };
561        let Some(mut device) = taken else {
562            return js_sys::Promise::reject(&JsValue::from_str("the device is already running"));
563        };
564        let steps = self.steps.clone();
565        let changes = self.changes.clone();
566        let (stop_tx, stop_rx) = futures::channel::oneshot::channel::<()>();
567        *self.stop.borrow_mut() = Some(stop_tx);
568        wasm_bindgen_futures::future_to_promise(async move {
569            let period = period_ms
570                .map(|ms| Duration::from_secs_f64(ms / 1_000.0))
571                .unwrap_or(Arora::DEFAULT_STEP_PERIOD);
572            let outcome = {
573                let run = device.run(period);
574                futures::pin_mut!(run);
575                // Each step runs inside one poll of the loop: folding after
576                // every poll folds every step's changes.
577                let run = futures::future::poll_fn(|cx| {
578                    let polled = run.as_mut().poll(cx);
579                    changes.borrow_mut().fold();
580                    polled
581                })
582                .fuse();
583                let mut stop_rx = stop_rx.fuse();
584                futures::pin_mut!(run);
585                futures::select_biased! {
586                    result = run => Some(result),
587                    _ = stop_rx => None,
588                }
589            };
590            // Whatever ended the loop, the device parks back in the scheduler: a
591            // failed run leaves a device the embedder can still step, probe
592            // ([`Arora::behavior_error`]), or run again. An operation the
593            // run's steps left unanswered now takes a step at a zero dt.
594            steps.device.borrow_mut().replace(device);
595            steps.stepped.send_replace(Stepped::Parked);
596            match outcome {
597                // `Arora::run` only returns by failing; surface that.
598                Some(result) => {
599                    result.map_err(|e| JsValue::from_str(&format!("run failed: {e}")))?;
600                    Ok(JsValue::UNDEFINED)
601                }
602                // Stopped: the loop ended between steps.
603                None => Ok(JsValue::UNDEFINED),
604            }
605        })
606    }
607
608    /// Reclaim the device from [`run`](Self::run): the loop ends at its next
609    /// step boundary, the run promise resolves, and `step()` (or another
610    /// `run()`) works again. A no-op while nothing runs.
611    pub fn stop(&self) {
612        if let Some(stop) = self.stop.borrow_mut().take() {
613            let _ = stop.send(());
614        }
615    }
616
617    /// Whether [`run`](Self::run) currently owns the device.
618    #[wasm_bindgen(getter)]
619    pub fn running(&self) -> bool {
620        // Held mid-step by a page's own step or a zero-dt one: not running.
621        self.steps
622            .device
623            .try_borrow()
624            .is_ok_and(|parked| parked.is_none())
625    }
626
627    /// The behavior's standing error — the message of its latest failed
628    /// tick, `undefined` while the behavior is healthy or none is installed.
629    /// A failing tick does not stop the device; the reading stays available
630    /// while `run()` owns it.
631    #[wasm_bindgen(getter, js_name = behaviorError)]
632    pub fn behavior_error(&self) -> Option<String> {
633        self.behavior_error.borrow().clone()
634    }
635
636    /// Resolves on the next change of the behavior's standing error, with
637    /// the new reading: a message when a distinct failure appears,
638    /// `undefined` when a tick recovers. Sequential awaits share one cursor,
639    /// so no change is missed between them; one await may be pending at a
640    /// time. Rejects when the device is gone.
641    #[wasm_bindgen(js_name = behaviorErrorChanged)]
642    pub fn behavior_error_changed(&self) -> js_sys::Promise {
643        let Some(mut errors) = self.behavior_error_changes.borrow_mut().take() else {
644            return js_sys::Promise::reject(&JsValue::from_str(
645                "a behaviorErrorChanged await is already pending",
646            ));
647        };
648        let home = self.behavior_error_changes.clone();
649        wasm_bindgen_futures::future_to_promise(async move {
650            let outcome = errors.changed().await;
651            let value = errors.borrow_and_update().clone();
652            home.borrow_mut().replace(errors);
653            outcome.map_err(|_| JsValue::from_str("the device is gone"))?;
654            Ok(match value {
655                Some(message) => JsValue::from_str(&message),
656                None => JsValue::UNDEFINED,
657            })
658        })
659    }
660
661    /// Dispatch a [`Call`] into the device through its in-process caller.
662    /// `call_json` is an `arora_types::call::Call` as JSON; the promise
663    /// resolves to the `CallResult` as a JSON string once the call is applied
664    /// (see [`AroraWeb`] for which step applies it).
665    pub fn call(&self, call_json: &str) -> js_sys::Promise {
666        let call: Call = match serde_json::from_str(call_json) {
667            Ok(call) => call,
668            Err(e) => return reject(format!("invalid call json: {e}")),
669        };
670        let reply = self.call_now(call);
671        promise(async move {
672            let result = reply
673                .await
674                .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
675            let json = serde_json::to_string(&result)
676                .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))?;
677            Ok(JsValue::from_str(&json))
678        })
679    }
680
681    /// Call the device's method named `method` with arguments by parameter
682    /// name, as a remote client invokes it over a bridge.
683    ///
684    /// `args_json` is a JSON object mapping each parameter name to an Arora
685    /// [`Value`] in the JSON form `call` takes, e.g. `{"x": {"f64": 2.5}}`. The
686    /// promise resolves to the return value, as a plain JS value of that JSON
687    /// form. A task-shaped method — one returning the behavior `Status` — is
688    /// spawned as a run instead, and resolves to the run's handle, the plain
689    /// object [`spawn`](Self::spawn) answers with.
690    ///
691    /// Names are the bare names modules export, so two modules may share one:
692    /// `module_id` (a uuid string) names the exporting module to choose. A name
693    /// more than one module exports, without `module_id`, rejects naming those
694    /// modules. An argument the method does not declare rejects too.
695    ///
696    /// The method's signature is read at once, from the device's method
697    /// index; its call is applied as `call`'s is.
698    pub fn invoke(
699        &self,
700        method: &str,
701        args_json: &str,
702        module_id: Option<String>,
703    ) -> js_sys::Promise {
704        let failed = |e: String| reject(format!("invoke {method} failed: {e}"));
705        let args: HashMap<String, Value> = match serde_json::from_str(args_json) {
706            Ok(args) => args,
707            Err(e) => return reject(format!("invalid args json: {e}")),
708        };
709        let module = match module_id.map(|id| Uuid::parse_str(&id).map_err(|e| (id, e))) {
710            None => None,
711            Some(Ok(module)) => Some(module),
712            Some(Err((id, e))) => return reject(format!("'{id}' is not a module id: {e}")),
713        };
714        let signature = match client::find_method(&self.methods, method, module) {
715            Ok(signature) => signature,
716            Err(e) => return failed(e),
717        };
718        let call = match client::call_of(signature, args) {
719            Ok(call) => call,
720            Err(e) => return failed(e),
721        };
722        let method = method.to_string();
723        let failed = move |e: CallError| JsValue::from_str(&format!("invoke {method} failed: {e}"));
724        if client::task_shaped(&signature.function) {
725            let caller = self.caller.clone();
726            let started = self.complete(async move { caller.spawn(call).await });
727            promise(async move { run_to_js(&started.await.map_err(failed)?) })
728        } else {
729            let returned = self.call_now(call);
730            promise(async move { value_to_js(&returned.await.map_err(failed)?.ret) })
731        }
732    }
733
734    /// Start `call_json` (an `arora_types::call::Call` as JSON) as a task run,
735    /// concurrently with every other run. The promise resolves to the run's
736    /// handle, a plain object `{run, status, feedback, result, update}`: `run`
737    /// the run id (a uuid string, which [`halt`](Self::halt) takes), `status`
738    /// the path of the key that reports how the run goes and ends, and
739    /// `feedback`, `result` and `update` the paths of the keys carrying its
740    /// progress, its result, and what steers it. It resolves once the spawn is
741    /// applied, as `call`'s does.
742    pub fn spawn(&self, call_json: &str) -> js_sys::Promise {
743        let call: Call = match serde_json::from_str(call_json) {
744            Ok(call) => call,
745            Err(e) => return reject(format!("invalid call json: {e}")),
746        };
747        let caller = self.caller.clone();
748        let started = self.complete(async move { caller.spawn(call).await });
749        promise(async move {
750            let handle = started
751                .await
752                .map_err(|e| JsValue::from_str(&format!("spawn failed: {e}")))?;
753            run_to_js(&handle)
754        })
755    }
756
757    /// Stop the run `run_id` names (the `run` of its handle, a uuid string):
758    /// it ends `Failure` on the step after the halt is applied. Idempotent —
759    /// halting a finished or unknown run resolves all the same. It resolves
760    /// once the halt is applied, as `call`'s does.
761    pub fn halt(&self, run_id: &str) -> js_sys::Promise {
762        let run = match Uuid::parse_str(run_id) {
763            Ok(run) => TaskId(run),
764            Err(e) => return reject(format!("'{run_id}' is not a run id: {e}")),
765        };
766        let caller = self.caller.clone();
767        let halted = self.complete(async move { caller.halt(run).await });
768        promise(async move {
769            halted
770                .await
771                .map_err(|e| JsValue::from_str(&format!("halt failed: {e}")))?;
772            Ok(JsValue::UNDEFINED)
773        })
774    }
775
776    /// The device's keys whose path starts with `prefix` (all of them when
777    /// absent), sorted by path. The promise resolves to an array of
778    /// `{path, __meta}` objects: each key with what its store says it is — the
779    /// shape it holds, its range and unit, where it rests, whether a client may
780    /// write it — as every Arora bridge lists keys. It answers from the store
781    /// as it is, with no step, and resolves at once.
782    #[wasm_bindgen(js_name = listKeys)]
783    pub fn list_keys(&self, prefix: Option<String>) -> js_sys::Promise {
784        let keys: Vec<KeyInfo> = arora::list_keys(&*self.store, prefix.as_deref())
785            .into_iter()
786            .map(|(path, meta)| KeyInfo { path, meta })
787            .collect();
788        settled(to_js(&keys))
789    }
790
791    /// The device's callable methods whose name starts with `prefix` (all of
792    /// them when absent), sorted by name. The promise resolves to an array of
793    /// method descriptions, as a bridge lists methods — `path` (the name),
794    /// `params` (each `name`, `param_type`, `required`, in declaration order),
795    /// `return_type`, `task` (whether invoking it starts a run) — plus `module`,
796    /// the exporting module's id, which [`invoke`](Self::invoke) takes when two
797    /// modules export one name. It answers from the device's method index,
798    /// with no step, and resolves at once.
799    #[wasm_bindgen(js_name = describeMethods)]
800    pub fn describe_methods(&self, prefix: Option<String>) -> js_sys::Promise {
801        let described = self
802            .methods
803            .iter()
804            .filter(|signature| {
805                prefix
806                    .as_deref()
807                    .is_none_or(|prefix| signature.name.starts_with(prefix))
808            })
809            .map(|signature| {
810                let mut method = serde_json::to_value(client::method_info(signature))
811                    .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))?;
812                method["module"] = serde_json::Value::String(signature.module_id.to_string());
813                Ok(method)
814            })
815            .collect::<Result<Vec<_>, JsValue>>();
816        settled(described.and_then(|methods| to_js(&methods)))
817    }
818
819    /// Write one key into the store (Arora [`Value`] as JSON, e.g. `{"f32": 1}`).
820    /// The store holds it when this returns; the step that carries it to the
821    /// HAL and the bridges is the one a client operation takes (see
822    /// [`AroraWeb`]).
823    #[wasm_bindgen(js_name = setValue)]
824    pub fn set_value(&self, path: &str, value_json: &str) -> Result<(), JsValue> {
825        let written = store_json::set_value(&*self.store, path, value_json);
826        self.wrote(written)
827    }
828
829    /// Write several keys at once, as one store change (a JSON object mapping
830    /// each key path to an Arora [`Value`]). The store holds them when this
831    /// returns, and a step carries them out as `setValue`'s.
832    #[wasm_bindgen(js_name = writeValues)]
833    pub fn write_values(&self, values_json: &str) -> Result<(), JsValue> {
834        let written = store_json::write_values(&*self.store, values_json);
835        self.wrote(written)
836    }
837
838    /// Read keys from the store; `paths` is a `string[]`, result maps path→Value.
839    #[wasm_bindgen(js_name = readValues)]
840    pub fn read_values(&self, paths: JsValue) -> Result<JsValue, JsValue> {
841        store_json::read_values(&*self.store, paths)
842    }
843
844    /// A snapshot of every key in the store as a path→Value object.
845    pub fn snapshot(&self) -> Result<JsValue, JsValue> {
846        store_json::snapshot(&*self.store)
847    }
848
849    /// Drain the keys that changed since the last call, as a path→Value object
850    /// holding each key's latest value (`null` for a cleared key): the net
851    /// change since the previous drain. The first drain returns the store's
852    /// whole current state — the subscription opens on it. What is not
853    /// drained is held as one value per key, however long the page goes
854    /// without draining.
855    #[wasm_bindgen(js_name = drainChanges)]
856    pub fn drain_changes(&self) -> Result<JsValue, JsValue> {
857        self.changes.borrow_mut().drain()
858    }
859}
860
861impl AroraWeb {
862    /// The device's in-process caller: the typed operations behind `call`,
863    /// `invoke`, `spawn` and `halt`, for a Rust crate that wraps this device
864    /// and wants them unserialized. An operation through it is a remote's: it
865    /// applies at the device's next step. Hand its reply to
866    /// [`complete`](Self::complete) to have it settle as this surface's
867    /// operations do.
868    pub fn caller(&self) -> LocalCaller {
869        self.caller.clone()
870    }
871
872    /// Settle `reply`, the reply of an operation that changes the device, as
873    /// this surface's own `call`, `invoke`, `spawn` and `halt` settle: applied
874    /// by a step at a zero dt while no [`run`](Self::run) is ongoing — the
875    /// operations issued in one turn sharing one — and by the run's next step
876    /// while one is.
877    ///
878    /// `reply` is polled once before this returns, so an operation that
879    /// queues itself at its future's first poll (a [`LocalCaller`] call
880    /// wrapped in an `async` block) is queued by then, behind those issued
881    /// before it. A reply that is already there settles at once. A step that
882    /// fails rejects every operation it left unanswered with its error,
883    /// without withdrawing it (see [`AroraWeb`]). A read-only operation has nothing to apply: answer it from the
884    /// device's state instead (`listKeys`, `describeMethods`).
885    pub fn complete<V: 'static>(
886        &self,
887        reply: impl Future<Output = Result<V, CallError>> + 'static,
888    ) -> impl Future<Output = Result<V, CallError>> + 'static {
889        let mut reply = Box::pin(reply);
890        let first = reply.as_mut().poll(&mut std::task::Context::from_waker(
891            futures::task::noop_waker_ref(),
892        ));
893        let mut stepped = self.steps.stepped.subscribe();
894        let steps = self.steps.clone();
895        if first.is_pending() {
896            steps.request();
897        }
898        async move {
899            if let Poll::Ready(answer) = first {
900                return answer;
901            }
902            loop {
903                {
904                    // The sender lives in `steps`, which this future holds:
905                    // the channel stays open while it waits.
906                    let changed = std::pin::pin!(stepped.changed());
907                    if let Either::Left((answer, _)) =
908                        futures::future::select(reply.as_mut(), changed).await
909                    {
910                        return answer;
911                    }
912                }
913                let event = stepped.borrow_and_update().clone();
914                match event {
915                    Stepped::Idle => {}
916                    Stepped::Failed(message) => {
917                        return match reply.as_mut().now_or_never() {
918                            Some(answer) => answer,
919                            None => Err(CallError::Generic {
920                                message: format!("step failed: {message}"),
921                            }),
922                        }
923                    }
924                    Stepped::Parked => steps.request(),
925                }
926            }
927        }
928    }
929
930    /// After a page's write to the store: fold it into what the page has not
931    /// drained, and have a step carry it to the HAL and the bridges — a step
932    /// at a zero dt while no run is ongoing, the run's next step while one is.
933    ///
934    /// A write from inside a step asks for none: the step that holds the
935    /// device flushes it, or, written once that step has flushed, the next
936    /// step does.
937    fn wrote(&self, written: Result<(), JsValue>) -> Result<(), JsValue> {
938        self.changes.borrow_mut().fold();
939        if written.is_ok() && !self.steps.mid_step() {
940            self.steps.request();
941        }
942        written
943    }
944
945    /// Queue `call` and settle its reply as [`complete`](Self::complete) does.
946    fn call_now(
947        &self,
948        call: Call,
949    ) -> impl Future<Output = Result<arora_types::call::CallResult, CallError>> + 'static {
950        let caller = self.caller.clone();
951        self.complete(async move { caller.call(call).await })
952    }
953}
954
955/// A promise rejected with `message`.
956fn reject(message: String) -> js_sys::Promise {
957    js_sys::Promise::reject(&JsValue::from_str(&message))
958}
959
960/// A promise already settled with `answer`.
961fn settled(answer: Result<JsValue, JsValue>) -> js_sys::Promise {
962    match answer {
963        Ok(value) => js_sys::Promise::resolve(&value),
964        Err(error) => js_sys::Promise::reject(&error),
965    }
966}
967
968/// Assemble a browser device from what the JS world can supply — guest wasm
969/// modules and the records they are described by, over the demo defaults;
970/// exported to JavaScript as `AroraRuntimeBuilder`. A device that needs a
971/// real HAL, bridge, store, or behavior interpreter is composed in Rust with
972/// [`arora::AroraBuilder`] and wrapped with `AroraWeb::from` instead: those
973/// seams are trait objects that cannot cross the JS boundary.
974#[wasm_bindgen(js_name = AroraRuntimeBuilder)]
975#[derive(Default)]
976pub struct AroraWebBuilder {
977    inner: AroraBuilder,
978    records: arora::LocalRegistry,
979}
980
981#[wasm_bindgen(js_class = "AroraRuntimeBuilder")]
982impl AroraWebBuilder {
983    #[wasm_bindgen(constructor)]
984    pub fn new() -> AroraWebBuilder {
985        AroraWebBuilder::default()
986    }
987
988    /// Load a guest module into the device's engine — in the browser, a
989    /// `.wasm` run by the native `WebAssembly` host. `header_json` is the
990    /// module's low-level header as JSON; `executable` its wasm bytes.
991    /// Repeatable — each call loads one module.
992    #[wasm_bindgen(js_name = withModule)]
993    pub fn with_module(&mut self, header_json: &str, executable: &[u8]) -> Result<(), JsValue> {
994        let header: Header = serde_json::from_str(header_json)
995            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
996        let inner = std::mem::take(&mut self.inner);
997        self.inner = inner.with_module(header, executable.to_vec());
998        Ok(())
999    }
1000
1001    /// Load a guest module compiled ahead into the device's engine, as
1002    /// `withModule` loads one from its bytes. The device instantiates the
1003    /// compiled `WebAssembly.Module` instead of compiling the bytes, so devices
1004    /// built from one `CompiledModule` compile it once between them.
1005    /// Repeatable — each call loads one module.
1006    #[wasm_bindgen(js_name = withCompiledModule)]
1007    pub fn with_compiled_module(&mut self, module: &CompiledModule) {
1008        let inner = std::mem::take(&mut self.inner);
1009        self.inner = inner.with_compiled_module(&module.inner);
1010    }
1011
1012    /// Add the records guest modules are described by: a module's
1013    /// `records/` directory as its package ships it — an object mapping each
1014    /// record file's path under `records/` (`module/<id>@<version>.yaml`,
1015    /// `structure/…`, `enumeration/…`, `folder/<id>.yaml`) to its YAML.
1016    /// Repeatable: the records of several modules fill one registry, a
1017    /// record they share held once.
1018    ///
1019    /// A guest module whose record the device holds has every export
1020    /// described — one taking or returning a structure included — and the
1021    /// build fails when that record does not match the module's header or
1022    /// lacks a record it depends on (`AroraBuilder::with_registry`). Fails
1023    /// here on a path outside that layout, a record that does not read, or a
1024    /// record another call gave differently under the same id and version;
1025    /// the records read before a failure stay, so a page builds a new
1026    /// builder rather than retrying on this one.
1027    #[wasm_bindgen(js_name = withRecords)]
1028    pub fn with_records(
1029        &mut self,
1030        #[wasm_bindgen(unchecked_param_type = "Record<string, string>")] records: JsValue,
1031    ) -> Result<(), JsValue> {
1032        use arora_registry::local_yaml::{load_records, RecordKind};
1033        use futures::FutureExt;
1034
1035        let files: std::collections::BTreeMap<String, String> =
1036            serde_wasm_bindgen::from_value(records).map_err(|e| {
1037                JsValue::from_str(&format!(
1038                    "records must map each record file's path to its YAML: {e}"
1039                ))
1040            })?;
1041        let mut entries = Vec::with_capacity(files.len());
1042        for (path, yaml) in &files {
1043            let record = path.split_once('/').and_then(|(directory, file)| {
1044                Some((
1045                    RecordKind::from_directory(directory)?,
1046                    file.strip_suffix(".yaml")?,
1047                ))
1048            });
1049            let Some((kind, stem)) = record else {
1050                return Err(JsValue::from_str(&format!(
1051                    "'{path}' is not a record file's path: <folder|enumeration|structure|module>/<id>[@<version>].yaml"
1052                )));
1053            };
1054            entries.push((kind, stem, yaml.clone()));
1055        }
1056        load_records(entries, &mut self.records)
1057            .now_or_never()
1058            .ok_or_else(|| JsValue::from_str("the records did not load synchronously"))?
1059            .map_err(|e| JsValue::from_str(&format!("the records do not load: {e}")))
1060    }
1061
1062    /// Build the device. The builder resets to its defaults, ready to
1063    /// assemble another.
1064    pub fn build(&mut self) -> Result<AroraWeb, JsValue> {
1065        let arora = std::mem::take(&mut self.inner)
1066            .with_registry(std::mem::take(&mut self.records))
1067            .build()
1068            .map_err(|e| JsValue::from_str(&format!("arora build failed: {e:?}")))?;
1069        Ok(AroraWeb::from(arora))
1070    }
1071}
1072
1073/// A guest module compiled once, to load into any number of devices with
1074/// `AroraRuntimeBuilder.withCompiledModule`: each device instantiates it —
1075/// its own memory and state — without compiling it again.
1076#[wasm_bindgen]
1077pub struct CompiledModule {
1078    inner: arora::CompiledModule,
1079}
1080
1081#[wasm_bindgen]
1082impl CompiledModule {
1083    /// Compile a guest module. `header_json` is the module's low-level header
1084    /// as JSON; `executable` its wasm bytes. Compiles synchronously
1085    /// (`new WebAssembly.Module`), which Chrome rejects above 8 MB on the main
1086    /// thread. Fails on an invalid header or an executable that does not
1087    /// compile.
1088    #[wasm_bindgen(constructor)]
1089    pub fn new(header_json: &str, executable: &[u8]) -> Result<CompiledModule, JsValue> {
1090        install_panic_hook();
1091        let header: Header = serde_json::from_str(header_json)
1092            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
1093        let inner = arora::CompiledModule::new(header, executable)
1094            .map_err(|e| JsValue::from_str(&format!("module does not compile: {e}")))?;
1095        Ok(CompiledModule { inner })
1096    }
1097}
1098
1099/// JS-callable handle to a configured Arora engine.
1100#[wasm_bindgen]
1101pub struct Engine {
1102    inner: std::pin::Pin<Box<arora_engine::engine::Engine>>,
1103    loader: SharedLoaderRc,
1104    function_module: HashMap<Uuid, Uuid>,
1105    module_headers: HashMap<Uuid, String>,
1106}
1107
1108#[wasm_bindgen]
1109impl Engine {
1110    #[wasm_bindgen(constructor)]
1111    pub fn new() -> Engine {
1112        install_panic_hook();
1113        let executor = BrowserExecutor::new();
1114        let loader = executor.shared();
1115        let inner = EngineBuilder::new().add_executor(executor).build();
1116        Engine {
1117            inner,
1118            loader,
1119            function_module: HashMap::new(),
1120            module_headers: HashMap::new(),
1121        }
1122    }
1123
1124    /// Load a module given its header (as JSON) and executable bytes.
1125    /// Returns the module's UUID as a string.
1126    ///
1127    /// Compiles and instantiates synchronously: Chrome rejects both above
1128    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
1129    /// for large executables.
1130    #[wasm_bindgen(js_name = loadModule)]
1131    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
1132        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
1133    }
1134
1135    /// Asynchronously compile and instantiate a module's executable
1136    /// (via `WebAssembly.instantiate`, no main-thread size limit).
1137    /// Follow up with `loadPreparedModule` to complete the load.
1138    #[wasm_bindgen(js_name = prepareModule)]
1139    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
1140        prepare_module_impl(self.loader.clone(), header_json, executable)
1141    }
1142
1143    /// Load a module whose executable was staged by `prepareModule`.
1144    /// Returns the module's UUID as a string.
1145    #[wasm_bindgen(js_name = loadPreparedModule)]
1146    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
1147        self.load_module_inner(header_json, Box::new([]))
1148    }
1149
1150    fn load_module_inner(
1151        &mut self,
1152        header_json: &str,
1153        executable: Box<[u8]>,
1154    ) -> Result<String, JsValue> {
1155        let header: Header = serde_json::from_str(header_json)
1156            .map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
1157        let header_json_str = header_json.to_string();
1158        let loaded = load_module_from_parts(&mut self.inner, header, executable)
1159            .map_err(|e| JsValue::from_str(&format!("load_module failed: {e}")))?;
1160        for fn_id in &loaded.function_ids {
1161            self.function_module.insert(*fn_id, loaded.id);
1162        }
1163        self.module_headers.insert(loaded.id, header_json_str);
1164        Ok(loaded.id.to_string())
1165    }
1166
1167    /// Returns a JSON array of all loaded module headers.
1168    #[wasm_bindgen(js_name = listModules)]
1169    pub fn list_modules(&self) -> String {
1170        let headers: Vec<serde_json::Value> = self
1171            .module_headers
1172            .values()
1173            .filter_map(|s| serde_json::from_str(s).ok())
1174            .collect();
1175        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
1176    }
1177
1178    /// Call a function. `call_json` is a JSON document matching
1179    /// `arora_engine::call::Call`. Returns the result as a JSON string.
1180    #[wasm_bindgen]
1181    pub fn call(&mut self, call_json: &str) -> Result<String, JsValue> {
1182        let mut call: Call = serde_json::from_str(call_json)
1183            .map_err(|e| JsValue::from_str(&format!("invalid call json: {e}")))?;
1184        if call.module_id.is_none() {
1185            // Resolve locally: the module the function was loaded with.
1186            call.module_id = Some(
1187                *self
1188                    .function_module
1189                    .get(&call.id)
1190                    .ok_or_else(|| JsValue::from_str("no module known for function"))?,
1191            );
1192        }
1193        let result = self
1194            .inner
1195            .arora_call(call)
1196            .map_err(|e| JsValue::from_str(&format!("call failed: {e}")))?;
1197        serde_json::to_string(&result)
1198            .map_err(|e| JsValue::from_str(&format!("serialize failed: {e}")))
1199    }
1200}
1201
1202impl Default for Engine {
1203    fn default() -> Self {
1204        Self::new()
1205    }
1206}
1207
1208/// Shared implementation of `prepareModule`: parses the header for the
1209/// module id, then stages an asynchronously-instantiated module in the
1210/// loader. Returns a `Promise<void>`.
1211fn prepare_module_impl(
1212    loader: SharedLoaderRc,
1213    header_json: &str,
1214    executable: Vec<u8>,
1215) -> js_sys::Promise {
1216    let header: Result<Header, _> = serde_json::from_str(header_json);
1217    wasm_bindgen_futures::future_to_promise(async move {
1218        let header = header.map_err(|e| JsValue::from_str(&format!("invalid header json: {e}")))?;
1219        loader.prepare(header.id, executable).await?;
1220        Ok(JsValue::UNDEFINED)
1221    })
1222}
1223
1224// =============================================================================
1225// Behavior-tree runner
1226//
1227// A self-contained BT runtime built directly on arora's engine primitives —
1228// no dependency on the arora-behavior-tree crate.
1229// =============================================================================
1230
1231// UUID constants from arora-behavior-tree-types generated code.
1232// TickId struct id: 6f49e650-84ca-4899-a9bd-1f3bf17fab51
1233const TICK_ID_STRUCT_BYTES: [u8; 16] = [
1234    0x6f, 0x49, 0xe6, 0x50, 0x84, 0xca, 0x48, 0x99, 0xa9, 0xbd, 0x1f, 0x3b, 0xf1, 0x7f, 0xab, 0x51,
1235];
1236// TickId::callable_id field: 237992d2-17d1-459f-bca1-7185fa6a69d7
1237const TICK_ID_CALLABLE_FIELD_BYTES: [u8; 16] = [
1238    0x23, 0x79, 0x92, 0xd2, 0x17, 0xd1, 0x45, 0x9f, 0xbc, 0xa1, 0x71, 0x85, 0xfa, 0x6a, 0x69, 0xd7,
1239];
1240// Status::Success variant: 766e9e9a-446d-4e46-83e6-14b7ca101169
1241const STATUS_SUCCESS_BYTES: [u8; 16] = [
1242    0x76, 0x6e, 0x9e, 0x9a, 0x44, 0x6d, 0x4e, 0x46, 0x83, 0xe6, 0x14, 0xb7, 0xca, 0x10, 0x11, 0x69,
1243];
1244// Status::Failure variant: 2468f46c-bb60-425c-9a4d-9ad326ccc7e2
1245const STATUS_FAILURE_BYTES: [u8; 16] = [
1246    0x24, 0x68, 0xf4, 0x6c, 0xbb, 0x60, 0x42, 0x5c, 0x9a, 0x4d, 0x9a, 0xd3, 0x26, 0xcc, 0xc7, 0xe2,
1247];
1248// Status enum type: 325a5767-e344-4532-860e-0749bcf2e428
1249const STATUS_ENUM_BYTES: [u8; 16] = [
1250    0x32, 0x5a, 0x57, 0x67, 0xe3, 0x44, 0x45, 0x32, 0x86, 0x0e, 0x07, 0x49, 0xbc, 0xf2, 0xe4, 0x28,
1251];
1252// _ret special out-parameter: 5f726574-0000-4000-8000-000000000000 ("_ret" in ASCII)
1253const RET_PARAM_BYTES: [u8; 16] = [
1254    0x5f, 0x72, 0x65, 0x74, 0x00, 0x00, 0x40, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
1255];
1256
1257fn value_to_status(v: &Value) -> &'static str {
1258    if let Value::Enumeration(e) = v {
1259        if *e.variant_id.as_bytes() == STATUS_SUCCESS_BYTES {
1260            return "success";
1261        }
1262        if *e.variant_id.as_bytes() == STATUS_FAILURE_BYTES {
1263            return "failure";
1264        }
1265    }
1266    "running"
1267}
1268
1269/// A node argument expression: either a literal value or a reference to a
1270/// named variable (identified by UUID).
1271#[derive(serde::Deserialize, Clone, Debug)]
1272#[serde(rename_all = "snake_case")]
1273enum BtExpression {
1274    VariableId(Uuid),
1275    Value(Value),
1276}
1277
1278/// A single BT node – mirrors the arora-behavior-tree YAML schema.
1279#[derive(serde::Deserialize, Debug)]
1280struct BtNode {
1281    id: Uuid,
1282    function: Uuid,
1283    #[serde(default)]
1284    children: Option<Vec<Uuid>>,
1285    /// Parameter arguments: maps parameter UUID to a literal value or variable.
1286    /// Use the special key `5f726574-0000-4000-8000-000000000000` (_ret) to
1287    /// capture the return value into a variable; the node then always succeeds.
1288    #[serde(default)]
1289    arguments: HashMap<Uuid, BtExpression>,
1290}
1291
1292/// Metadata extracted from a module header for one exported function.
1293struct FnMeta {
1294    module_id: Uuid,
1295    /// Parameter ID of the `children: TickId[]` argument, present only for
1296    /// control nodes (seq, fallback, parallel …).
1297    children_param_id: Option<Uuid>,
1298}
1299
1300/// An arora Callable that wraps one BT node.
1301struct NodeCallable {
1302    node_id: Uuid,
1303    fn_id: Uuid,
1304    module_id: Uuid,
1305    children_param_id: Option<Uuid>,
1306    children_callable_ids: Vec<u64>,
1307    arguments: HashMap<Uuid, BtExpression>,
1308    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
1309    trace: Rc<RefCell<Vec<(Uuid, &'static str)>>>,
1310}
1311
1312impl Callable for NodeCallable {
1313    fn call(&self, caller: &mut dyn CallBridge) -> Result<Value, arora_engine::call::CallError> {
1314        let tick_id_type = Uuid::from_bytes(TICK_ID_STRUCT_BYTES);
1315        let callable_field = Uuid::from_bytes(TICK_ID_CALLABLE_FIELD_BYTES);
1316        let ret_param_id = Uuid::from_bytes(RET_PARAM_BYTES);
1317
1318        let mut args = Vec::new();
1319
1320        if let Some(children_param_id) = self.children_param_id {
1321            let elements: Vec<StructureWithoutId> = self
1322                .children_callable_ids
1323                .iter()
1324                .map(|&id| StructureWithoutId {
1325                    fields: vec![StructureField {
1326                        id: callable_field,
1327                        value: Box::new(Value::U64(id)),
1328                    }],
1329                })
1330                .collect();
1331            args.push(StructureField {
1332                id: children_param_id,
1333                value: Box::new(Value::ArrayStructure {
1334                    id: tick_id_type,
1335                    elements,
1336                }),
1337            });
1338        }
1339
1340        for (&param_id, expr) in &self.arguments {
1341            if param_id == ret_param_id {
1342                continue;
1343            }
1344            let value = match expr {
1345                BtExpression::Value(v) => v.clone(),
1346                BtExpression::VariableId(var_id) => self
1347                    .variables
1348                    .borrow()
1349                    .get(var_id)
1350                    .cloned()
1351                    .unwrap_or(Value::Unit),
1352            };
1353            args.push(StructureField {
1354                id: param_id,
1355                value: Box::new(value),
1356            });
1357        }
1358
1359        // Build a map of param_id -> variable_id for mutable arguments so we can
1360        // write mutated values back to the variable store after the call.
1361        let mutable_param_vars: HashMap<Uuid, Uuid> = self
1362            .arguments
1363            .iter()
1364            .filter_map(|(&param_id, expr)| {
1365                if param_id == ret_param_id {
1366                    return None;
1367                }
1368                if let BtExpression::VariableId(var_id) = expr {
1369                    Some((param_id, *var_id))
1370                } else {
1371                    None
1372                }
1373            })
1374            .collect();
1375
1376        let result = caller.arora_call(Call {
1377            module_id: Some(self.module_id),
1378            id: self.fn_id,
1379            args,
1380        })?;
1381
1382        // Write back mutated parameter values to bound variables.
1383        for mutated in &result.mutated {
1384            if let Some(&var_id) = mutable_param_vars.get(&mutated.id) {
1385                self.variables
1386                    .borrow_mut()
1387                    .insert(var_id, *mutated.value.clone());
1388            }
1389        }
1390
1391        let has_ret = self.arguments.contains_key(&ret_param_id);
1392        if has_ret {
1393            if let Some(BtExpression::VariableId(var_id)) = self.arguments.get(&ret_param_id) {
1394                self.variables
1395                    .borrow_mut()
1396                    .insert(*var_id, result.ret.clone());
1397            }
1398        }
1399
1400        let s = if has_ret {
1401            "success"
1402        } else {
1403            value_to_status(&result.ret)
1404        };
1405        self.trace.borrow_mut().push((self.node_id, s));
1406
1407        if has_ret {
1408            Ok(Value::Enumeration(Enumeration {
1409                id: Uuid::from_bytes(STATUS_ENUM_BYTES),
1410                variant_id: Uuid::from_bytes(STATUS_SUCCESS_BYTES),
1411                value: Box::new(Value::Unit),
1412            }))
1413        } else {
1414            Ok(result.ret)
1415        }
1416    }
1417}
1418
1419/// Recursively registers callables for `node_id` and all descendants.
1420/// Returns the callable id of the registered root callable.
1421fn register_node(
1422    engine: &mut dyn CallBridge,
1423    node_id: Uuid,
1424    node_index: &HashMap<Uuid, BtNode>,
1425    fn_meta: &HashMap<Uuid, FnMeta>,
1426    trace: &Rc<RefCell<Vec<(Uuid, &'static str)>>>,
1427    variables: &Rc<RefCell<HashMap<Uuid, Value>>>,
1428) -> Result<u64, String> {
1429    let node = node_index
1430        .get(&node_id)
1431        .ok_or_else(|| format!("node {node_id} not found in tree"))?;
1432    let meta = fn_meta
1433        .get(&node.function)
1434        .ok_or_else(|| format!("function {} not registered in fn_meta", node.function))?;
1435
1436    let children_callable_ids = match &node.children {
1437        None => vec![],
1438        Some(ids) => ids
1439            .iter()
1440            .map(|&child_id| register_node(engine, child_id, node_index, fn_meta, trace, variables))
1441            .collect::<Result<Vec<_>, _>>()?,
1442    };
1443
1444    let callable: Rc<dyn Callable> = Rc::new(NodeCallable {
1445        node_id,
1446        fn_id: node.function,
1447        module_id: meta.module_id,
1448        children_param_id: meta.children_param_id,
1449        children_callable_ids,
1450        arguments: node.arguments.clone(),
1451        variables: variables.clone(),
1452        trace: trace.clone(),
1453    });
1454    let id = engine.arora_register_callable(callable);
1455    Ok(id.id)
1456}
1457
1458/// JS-callable handle for loading modules and executing behavior trees.
1459///
1460/// Usage:
1461/// 1. `new BehaviorTreeRunner()`
1462/// 2. `runner.loadModule(headerJson, wasmBytes)` – can be called for multiple modules
1463/// 3. `runner.run(nodesJson)` – returns `{status, trace}`
1464/// 4. Or `runner.setVariable(varId, valueJson)` + `runner.tick(nodesJson)` for
1465///    stateful tick-by-tick execution with variable bindings.
1466#[wasm_bindgen]
1467pub struct BehaviorTreeRunner {
1468    inner: Pin<Box<arora_engine::engine::Engine>>,
1469    loader: SharedLoaderRc,
1470    fn_meta: HashMap<Uuid, FnMeta>,
1471    variables: Rc<RefCell<HashMap<Uuid, Value>>>,
1472    module_headers: HashMap<Uuid, String>,
1473}
1474
1475#[wasm_bindgen]
1476impl BehaviorTreeRunner {
1477    #[wasm_bindgen(constructor)]
1478    pub fn new() -> BehaviorTreeRunner {
1479        install_panic_hook();
1480        let executor = BrowserExecutor::new();
1481        let loader = executor.shared();
1482        let inner = EngineBuilder::new().add_executor(executor).build();
1483        BehaviorTreeRunner {
1484            inner,
1485            loader,
1486            fn_meta: HashMap::new(),
1487            variables: Rc::new(RefCell::new(HashMap::new())),
1488            module_headers: HashMap::new(),
1489        }
1490    }
1491
1492    /// Load a WASM module. `header_json` must be the module's YAML header
1493    /// converted to JSON (the JS side can use js-yaml for that).
1494    /// Returns the module UUID string.
1495    ///
1496    /// Compiles and instantiates synchronously: Chrome rejects both above
1497    /// 8 MB on the main thread — use `prepareModule` + `loadPreparedModule`
1498    /// for large executables.
1499    #[wasm_bindgen(js_name = loadModule)]
1500    pub fn load_module(&mut self, header_json: &str, executable: &[u8]) -> Result<String, JsValue> {
1501        self.load_module_inner(header_json, executable.to_vec().into_boxed_slice())
1502    }
1503
1504    /// Asynchronously compile and instantiate a module's executable
1505    /// (via `WebAssembly.instantiate`, no main-thread size limit).
1506    /// Follow up with `loadPreparedModule` to complete the load.
1507    #[wasm_bindgen(js_name = prepareModule)]
1508    pub fn prepare_module(&self, header_json: &str, executable: Vec<u8>) -> js_sys::Promise {
1509        prepare_module_impl(self.loader.clone(), header_json, executable)
1510    }
1511
1512    /// Load a module whose executable was staged by `prepareModule`.
1513    /// Returns the module UUID string.
1514    #[wasm_bindgen(js_name = loadPreparedModule)]
1515    pub fn load_prepared_module(&mut self, header_json: &str) -> Result<String, JsValue> {
1516        self.load_module_inner(header_json, Box::new([]))
1517    }
1518
1519    fn load_module_inner(
1520        &mut self,
1521        header_json: &str,
1522        executable: Box<[u8]>,
1523    ) -> Result<String, JsValue> {
1524        let header: Header = serde_json::from_str(header_json)
1525            .map_err(|e| JsValue::from_str(&format!("invalid header: {e}")))?;
1526        let module_id = header.id;
1527        let header_json_str = header_json.to_string();
1528
1529        for export in &header.exports {
1530            let arora_types::module::low::ExportSymbol::Function(f) = export;
1531            let children_param_id = f.parameters.first().and_then(|p| {
1532                if let arora_types::module::low::TypeRef::Array { id } = &p.ty {
1533                    if id == &Uuid::from_bytes(TICK_ID_STRUCT_BYTES) {
1534                        Some(p.id)
1535                    } else {
1536                        None
1537                    }
1538                } else {
1539                    None
1540                }
1541            });
1542
1543            self.fn_meta.insert(
1544                f.id,
1545                FnMeta {
1546                    module_id,
1547                    children_param_id,
1548                },
1549            );
1550        }
1551
1552        let result = load_module_from_parts(&mut self.inner, header, executable)
1553            .map_err(|e| JsValue::from_str(&format!("load failed: {e}")))?;
1554        self.module_headers.insert(result.id, header_json_str);
1555        Ok(result.id.to_string())
1556    }
1557
1558    /// Returns a JSON array of all loaded module headers.
1559    #[wasm_bindgen(js_name = listModules)]
1560    pub fn list_modules(&self) -> String {
1561        let headers: Vec<serde_json::Value> = self
1562            .module_headers
1563            .values()
1564            .filter_map(|s| serde_json::from_str(s).ok())
1565            .collect();
1566        serde_json::to_string(&headers).unwrap_or_else(|_| "[]".to_string())
1567    }
1568
1569    /// Initialize or update a variable. `var_id` is a UUID string; `value_json`
1570    /// is the serialized `Value` (e.g. `{"f32": 0.0}`).
1571    #[wasm_bindgen(js_name = setVariable)]
1572    pub fn set_variable(&mut self, var_id: &str, value_json: &str) -> Result<(), JsValue> {
1573        let var_id: Uuid = var_id
1574            .parse()
1575            .map_err(|_| JsValue::from_str("bad var_id: not a valid UUID"))?;
1576        let value: Value = serde_json::from_str(value_json)
1577            .map_err(|e| JsValue::from_str(&format!("bad value JSON: {e}")))?;
1578        self.variables.borrow_mut().insert(var_id, value);
1579        Ok(())
1580    }
1581
1582    /// Run one tick of the behavior tree. Variables persist across calls.
1583    ///
1584    /// `nodes_json` is a JSON array where each element is:
1585    ///   `{ id, function, children?, arguments?, return_binding? }`
1586    ///
1587    /// Returns: `{ "status": "...", "trace": [...], "variables": { varId: value } }`
1588    pub fn tick(&mut self, nodes_json: &str) -> Result<String, JsValue> {
1589        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
1590            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
1591        if nodes.is_empty() {
1592            return Err(JsValue::from_str("tree has no nodes"));
1593        }
1594
1595        let root_id = nodes[0].id;
1596        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
1597        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
1598
1599        let fn_meta = &self.fn_meta;
1600        let root_callable_id = register_node(
1601            &mut *self.inner,
1602            root_id,
1603            &node_index,
1604            fn_meta,
1605            &trace,
1606            &self.variables,
1607        )
1608        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
1609
1610        let callable_id = CallableId {
1611            id: root_callable_id,
1612        };
1613        let result = Callable::call(&callable_id, &mut *self.inner)
1614            .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
1615        let status = value_to_status(&result);
1616
1617        let trace_json: Vec<serde_json::Value> = trace
1618            .borrow()
1619            .iter()
1620            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
1621            .collect();
1622
1623        let vars_json: serde_json::Map<String, serde_json::Value> = self
1624            .variables
1625            .borrow()
1626            .iter()
1627            .filter_map(|(id, v)| serde_json::to_value(v).ok().map(|jv| (id.to_string(), jv)))
1628            .collect();
1629
1630        Ok(serde_json::json!({
1631          "status": status,
1632          "trace": trace_json,
1633          "variables": vars_json,
1634        })
1635        .to_string())
1636    }
1637
1638    /// Run a behavior tree to completion (ticks until not Running).
1639    ///
1640    /// `nodes_json` is a JSON array where each element is:
1641    ///   `{ id: "<uuid>", function: "<uuid>", children?: ["<uuid>", ...] }`
1642    /// The first element is the root node.
1643    ///
1644    /// Returns a JSON string:
1645    ///   `{ "status": "success"|"failure"|"running",
1646    ///      "trace": [{"nodeId": "<uuid>", "status": "..."}] }`
1647    pub fn run(&mut self, nodes_json: &str) -> Result<String, JsValue> {
1648        let nodes: Vec<BtNode> = serde_json::from_str(nodes_json)
1649            .map_err(|e| JsValue::from_str(&format!("bad nodes JSON: {e}")))?;
1650        if nodes.is_empty() {
1651            return Err(JsValue::from_str("tree has no nodes"));
1652        }
1653
1654        let root_id = nodes[0].id;
1655        let node_index: HashMap<Uuid, BtNode> = nodes.into_iter().map(|n| (n.id, n)).collect();
1656        let trace: Rc<RefCell<Vec<(Uuid, &'static str)>>> = Rc::new(RefCell::new(Vec::new()));
1657
1658        let fn_meta = &self.fn_meta;
1659        let root_callable_id = register_node(
1660            &mut *self.inner,
1661            root_id,
1662            &node_index,
1663            fn_meta,
1664            &trace,
1665            &self.variables,
1666        )
1667        .map_err(|e| JsValue::from_str(&format!("setup error: {e}")))?;
1668
1669        let callable_id = CallableId {
1670            id: root_callable_id,
1671        };
1672
1673        let mut last_status = "running";
1674        for _ in 0..10_000 {
1675            let result = Callable::call(&callable_id, &mut *self.inner)
1676                .map_err(|e| JsValue::from_str(&format!("tick error: {e}")))?;
1677            last_status = value_to_status(&result);
1678            if last_status != "running" {
1679                break;
1680            }
1681        }
1682
1683        let trace_json: Vec<serde_json::Value> = trace
1684            .borrow()
1685            .iter()
1686            .map(|(id, s)| serde_json::json!({ "nodeId": id.to_string(), "status": s }))
1687            .collect();
1688
1689        let out = serde_json::json!({
1690          "status": last_status,
1691          "trace": trace_json,
1692        });
1693        Ok(out.to_string())
1694    }
1695}
1696
1697impl Default for BehaviorTreeRunner {
1698    fn default() -> Self {
1699        Self::new()
1700    }
1701}
1702
1703// =============================================================================
1704// Type registry
1705//
1706// A minimal wasm-compatible registry for resolving type UUIDs to names.
1707// Loads from the records.json files emitted by arora-module-rust::generate_records.
1708// =============================================================================
1709
1710/// JS-callable type registry that resolves type UUIDs to human-readable names.
1711#[wasm_bindgen]
1712pub struct Registry {
1713    entries: HashMap<Uuid, (String, Option<Uuid>)>,
1714}
1715
1716#[derive(serde::Deserialize)]
1717struct RecordEntry {
1718    id: Uuid,
1719    name: String,
1720    parent: Option<Uuid>,
1721}
1722
1723#[wasm_bindgen]
1724impl Registry {
1725    #[wasm_bindgen(constructor)]
1726    pub fn new() -> Registry {
1727        Registry {
1728            entries: HashMap::new(),
1729        }
1730    }
1731
1732    /// Load type records from a JSON array: `[{id, name, parent?}, ...]`.
1733    #[wasm_bindgen(js_name = loadRecordsJson)]
1734    pub fn load_records_json(&mut self, json: &str) -> Result<(), JsValue> {
1735        let records: Vec<RecordEntry> = serde_json::from_str(json)
1736            .map_err(|e| JsValue::from_str(&format!("invalid records JSON: {e}")))?;
1737        for r in records {
1738            self.entries.insert(r.id, (r.name, r.parent));
1739        }
1740        Ok(())
1741    }
1742
1743    /// Resolve a UUID string to a dot-separated name path (e.g. `"arora.Status"`).
1744    /// Returns `None` if the UUID is not known.
1745    #[wasm_bindgen(js_name = resolveId)]
1746    pub fn resolve_id(&self, id: &str) -> Option<String> {
1747        let uuid: Uuid = id.parse().ok()?;
1748        self.compute_path(&uuid)
1749    }
1750
1751    fn compute_path(&self, id: &Uuid) -> Option<String> {
1752        let (name, parent) = self.entries.get(id)?;
1753        match parent {
1754            None => Some(name.clone()),
1755            Some(parent_id) if !self.entries.contains_key(parent_id) => Some(name.clone()),
1756            Some(parent_id) => Some(format!("{}.{}", self.compute_path(parent_id)?, name)),
1757        }
1758    }
1759}
1760
1761impl Default for Registry {
1762    fn default() -> Self {
1763        Self::new()
1764    }
1765}
1766
1767/// What a page has not drained stays one entry per changed key, whichever
1768/// way the device steps. Run in a browser with `wasm-pack test --headless
1769/// --firefox crates/arora-web --lib`.
1770#[cfg(test)]
1771mod undrained_tests {
1772    use super::*;
1773    use wasm_bindgen_futures::JsFuture;
1774    use wasm_bindgen_test::*;
1775
1776    wasm_bindgen_test_configure!(run_in_browser);
1777
1778    /// The keys a busy device changes on each of its steps.
1779    const KEYS: usize = 200;
1780
1781    /// Write every one of the [`KEYS`] to `value`, as one store change.
1782    fn frame(store: &dyn DataStore, value: f32) {
1783        let mut change = StateChange::new();
1784        for key in 0..KEYS {
1785            change
1786                .set
1787                .insert(Key::new(format!("face/{key}")), Some(Value::F32(value)));
1788        }
1789        store.write(change).expect("write a frame");
1790    }
1791
1792    /// A demo device and a sibling handle of its store, through which a test
1793    /// writes as the device's own writers do.
1794    fn device() -> (AroraWeb, Box<dyn DataStore>) {
1795        let arora = Arora::builder().build().expect("build the device");
1796        let store = arora.store().clone_box();
1797        (AroraWeb::from(arora), store)
1798    }
1799
1800    /// Whether the subscription's channel is empty: every change it received
1801    /// has been folded.
1802    fn channel_is_empty(web: &AroraWeb) -> bool {
1803        web.changes.borrow().subscription.try_recv().is_none()
1804    }
1805
1806    /// The value the backlog holds for `face/{key}`.
1807    fn held(web: &AroraWeb, key: usize) -> Option<Value> {
1808        web.changes
1809            .borrow()
1810            .latest
1811            .get(&Key::new(format!("face/{key}")))
1812            .cloned()
1813            .flatten()
1814    }
1815
1816    async fn sleep(ms: i32) {
1817        let timer = js_sys::Promise::new(&mut |resolve, _| {
1818            let set_timeout: js_sys::Function =
1819                js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("setTimeout"))
1820                    .expect("a global setTimeout")
1821                    .into();
1822            set_timeout
1823                .call2(&JsValue::NULL, &resolve, &JsValue::from(ms))
1824                .expect("setTimeout");
1825        });
1826        JsFuture::from(timer).await.expect("the timer fires");
1827    }
1828
1829    #[wasm_bindgen_test]
1830    fn stepping_without_a_drain_holds_one_value_per_key() {
1831        let (web, store) = device();
1832        for step in 0..1_000 {
1833            frame(&*store, step as f32);
1834            web.step(10.0).expect("step");
1835            assert!(channel_is_empty(&web), "step {step} left changes unfolded");
1836        }
1837        let keys = store.snapshot().storage.len();
1838        assert!(keys >= KEYS, "the store holds the frame's keys");
1839        assert!(
1840            web.changes.borrow().latest.len() <= keys,
1841            "the backlog holds no more than one entry per key"
1842        );
1843        assert_eq!(held(&web, 7), Some(Value::F32(999.0)));
1844    }
1845
1846    #[wasm_bindgen_test]
1847    async fn a_running_device_folds_every_step() {
1848        let (web, store) = device();
1849        let running = web.run(Some(1.0));
1850        for step in 0..50 {
1851            frame(&*store, step as f32);
1852            // Several periods: the loop steps, and folds, before this resumes.
1853            sleep(5).await;
1854            assert!(channel_is_empty(&web), "step {step} left changes unfolded");
1855            assert_eq!(held(&web, 0), Some(Value::F32(step as f32)));
1856        }
1857        web.stop();
1858        JsFuture::from(running).await.expect("the run stops");
1859    }
1860
1861    #[wasm_bindgen_test]
1862    fn a_page_write_is_folded_at_once() {
1863        let (web, _store) = device();
1864        web.drain_changes().expect("the first drain");
1865        for value in 0..100 {
1866            web.set_value("face/0", &format!(r#"{{"f32": {value}}}"#))
1867                .expect("set a key");
1868            assert!(channel_is_empty(&web));
1869        }
1870        assert_eq!(held(&web, 0), Some(Value::F32(99.0)));
1871    }
1872}