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