arora-web 10.2.0

Run an Arora device in the browser: the AroraRuntime wasm-bindgen device over the arora runtime, plus engine and module tooling.
Documentation
# arora-web

Run an Arora device inside a browser. Compiles `arora` (with
`--no-default-features`, so no wasmtime, no libloading) to
`wasm32-unknown-unknown` and exposes two things:

- **`AroraRuntime`** (Rust name: `AroraWeb`) — the wasm-bindgen device over an
  `arora::Arora`: a synchronous `step()`, a self-pacing `run()`, in-process
  `call()` dispatch, and Value↔JSON store accessors.
- **`Engine`** / **`BehaviorTreeRunner`** — a lower-level JS surface for loading
  guest modules and running behavior trees directly on the engine.

## `AroraRuntime` (the device)

```ts
class AroraRuntime {
  constructor();                          // the demo device (fake HAL, no bridge)
  step(dtMs: number): void;               // one step, e.g. from requestAnimationFrame
  run(periodMs?: number): Promise<void>; // hands the device to arora's own loop; resolves on stop(), rejects when stepping fails
  stop(): void;                           // reclaims the device at the next step boundary; step() works again
  readonly running: boolean;              // whether run() currently owns the device
  readonly behaviorError?: string;        // the behavior's standing error; undefined while healthy
  behaviorErrorChanged(): Promise<string | undefined>; // resolves on the next standing-error change
  call(callJson: string): Promise<string>; // in-process Call; resolves to result JSON once applied
  invoke(method: string, argsJson: string, moduleId?: string): Promise<unknown | Run>; // by name; a Value's JSON, or a run's handle
  spawn(callJson: string): Promise<Run>;  // starts a task run; resolves to its handle
  halt(runId: string): Promise<void>;     // stops the run its handle's `run` names
  listKeys(prefix?: string): Promise<{ path: string; __meta: unknown }[]>; // answers at once, no step
  describeMethods(prefix?: string): Promise<unknown[]>; // as a bridge lists methods, plus each one's `module` id; no step
  setValue(path: string, valueJson: string): void;    // in the store at once; a step carries it to the HAL
  writeValues(valuesJson: string): void;
  readValues(paths: string[]): Record<string, unknown>;
  snapshot(): Record<string, unknown>;
  drainChanges(): Record<string, unknown>; // each changed key's latest value; first drain = the store's whole state
}

// A run's handle: its id, and the paths of the keys that follow and steer it.
type Run = { run: string; status: string; feedback: string[]; result: string[]; update: string[] };

class AroraRuntimeBuilder {
  constructor();
  withModule(headerJson: string, executable: Uint8Array): void; // repeatable
  withCompiledModule(module: CompiledModule): void;              // repeatable
  withRecords(records: Record<string, string>): void;            // repeatable
  build(): AroraRuntime;
}

// A guest module compiled once (sync compile, < 8 MB in Chrome), for any number of devices.
class CompiledModule {
  constructor(headerJson: string, executable: Uint8Array);
}
```

`withModule` compiles the module's bytes when the device is built, for that
device alone. Several devices running one module share its compilation by
loading one `CompiledModule` each with `withCompiledModule`: each device still
gets its own instance, with its own memory and state.

`withRecords` takes the records a module's package ships in its `records/`
directory: each record file's path under `records/`
(`module/<id>@<version>.yaml`, `structure/…`, `enumeration/…`,
`folder/<id>.yaml`) mapped to its YAML. A module whose record the device holds
has every export described by `describeMethods` and callable from a behavior
— those taking or returning a structure included; the build fails when the
record does not match the module's header. Without records, only the exports
whose types are primitives are described.

Values cross the JS boundary as JSON in the Arora `Value` vocabulary, e.g.
`{"f32": 0.75}`. `drainChanges` is the poll-based counterpart to a store
subscription (JavaScript can't await the std channel `DataStore::subscribe`
delivers on); a subscription opens on the store's whole current state, so the
first drain returns the full picture. Every step folds what the subscription
received into the latest value per key, so a drain answers the net change
since the previous one — a key set twice reads its second value, a key set
then unset reads `null` — and what a page has not drained is one entry per
key changed since its last drain, however many steps it goes without
draining.

`run()` hands the device to `arora::Arora::run` until `stop()` reclaims it —
the loop ends at its next step boundary, the run promise resolves, and
`step()` (or another `run()`) works again; while it runs, `step()` is
unavailable. The rest of the surface keeps working while the device runs:
`setValue`/`readValues`/`snapshot` work on a sibling handle of the store,
`drainChanges` on its subscription, and the client operations are the ones a
remote client has over a bridge.

### When an operation settles

The operations that change the device — `call`, `invoke`, `spawn`, `halt` —
are queued on the device's in-process `arora::LocalCaller` before the method
returns, and their promise settles on the reply of the step that applies them:

- **No `run()` ongoing:** the operation asks for a step at a zero dt, taken in
  the next microtask. A device nobody steps still settles every operation.
  The operations issued in one turn share one such step, which applies them
  in the order they were issued, with whatever the bridges delivered, and
  writes the HAL and the bridges what changed. A page that steps in the same
  turn (`call(…)` then `step(16)`) applies them with its own step, and no
  step at a zero dt follows.
- **`run()` ongoing:** the run's next step applies the operation. The page
  adds no step, so the run's cadence is its own. An operation the run leaves
  unapplied as `stop()` ends it takes a step at a zero dt once the device is
  back.

A page's writes to the store, `setValue` and `writeValues`, are in the store
when the method returns. The same steps carry them to the HAL and the
bridges: a step at a zero dt shared with the turn's operations, or the run's
next step.

A step at a zero dt is an ordinary step with no time elapsed. It applies what
was asked, publishes `arora/dt` as zero, ticks the behavior once and writes
the HAL, so precedence and change notifications are those of any step. What
runs on time holds still, while a node that counts ticks (a behavior tree's
`Repeat`, say) advances one tick. On a device the page steps itself, an
operation or a write made outside the turn of a `step()` takes a step at a
zero dt before the page's next one.

A step that fails, the page's own or one at a zero dt, rejects each operation
it left unanswered with its error, and a step at a zero dt says so on the
console. The operation is not withdrawn: if the step failed before applying
it, a later step still does.

The operations that only read, `listKeys` and `describeMethods`, never step.
They answer at once from the device's current state, whether or not `run()`
owns it: the keys from the store, and the methods from the method index read
when the device was wrapped. That index changes only through `Arora`'s module
methods, which `AroraWeb` does not call. `invoke` reads its method's signature
the same way, so its call is applied like `call`'s. A read does not see an
operation that is still queued, so await the operation first.

A call into this surface from a callback the step itself invokes (a module
calling a page function during a tick, which calls back into the device) is
an ordinary call. A step cannot nest, so an operation is queued and applied
by a step at a zero dt that runs as a task once the current step has ended,
or by the run's next step. A write is carried by the current step, or by the
next one if the current step has already flushed. `step()` from there is
refused. Because the step follows as a task, a callback that asks for one on
every tick does not starve the event loop, but it does keep the device
stepping. Don't rely on such calls. What a step needs done belongs in a
behavior, where it is part of the step.

`invoke` names a method by the bare name its module exports; when two modules
export one name, pass the module id (`describeMethods` gives each method's
`module`) — without it the promise rejects naming those modules. A task-shaped
method (one returning the behavior `Status`) is spawned as a run: like
`spawn`, it resolves to the run's handle, a plain object
`{ run, status, feedback, result, update }`: `run` is the id `halt` takes,
`status` the path of the key reporting how the run goes and ends, and the rest
the paths of the keys carrying its progress, its result, and what steers it.

A downstream device (e.g. Vizij) composes its own `arora::Arora` with
`arora::AroraBuilder` — the HAL, bridge, and store seams are trait objects that
cannot cross the JS boundary — and wraps it with `AroraWeb::from`, reusing the
same JS surface. The `store_json` module exposes the Value↔JSON accessors over
any store for wrappers that add their own methods, and `AroraWeb::caller` the
typed client operations. An operation through the caller is a remote's: it
applies at the device's next step. `AroraWeb::complete(reply)` settles it as
this surface's operations settle.

## Engine / BehaviorTreeRunner JS surface (see [src/lib.rs]src/lib.rs):

```ts
class Engine {
  constructor();
  loadModule(headerJson: string, executable: Uint8Array): string; // returns module id; sync compile (< 8 MB in Chrome)
  prepareModule(headerJson: string, executable: Uint8Array): Promise<void>; // async compile + instantiate, any size
  loadPreparedModule(headerJson: string): string;                   // completes a prepareModule load; returns module id
  call(callJson: string): string;                                   // returns result JSON
  listModules(): string;                                            // returns JSON array of loaded module headers
}

class BehaviorTreeRunner {
  constructor();
  loadModule(headerJson: string, executable: Uint8Array): string;
  prepareModule(headerJson: string, executable: Uint8Array): Promise<void>;
  loadPreparedModule(headerJson: string): string;
  listModules(): string;                                            // returns JSON array of loaded module headers
  setVariable(varId: string, valueJson: string): void;
  tick(nodesJson: string): string;  // returns {status, trace, variables}
  run(nodesJson: string): string;   // runs until not Running
}
```

`loadModule` compiles and instantiates synchronously, which Chrome rejects
above 8 MB on the main thread. The `prepareModule` + `loadPreparedModule`
pair routes through the async `WebAssembly.instantiate` and works for any
size in every browser.

`callJson` matches `arora_engine::call::Call`:
`{"id":"<function-uuid>","args":[...]}`. If the call doesn't carry a
`module_id`, arora-web looks one up from the function ID it was loaded
with.

Each node in `nodesJson` is:
```json
{
  "id": "<uuid>",
  "function": "<fn-uuid>",
  "children": ["<child-uuid>"],
  "arguments": { "<param-uuid>": {"value": {...}} | {"variable_id": "<uuid>"} },
  "return_binding": "<var-uuid>"
}
```
When `return_binding` is set the function's raw return value is stored in
that variable and the node always reports `success` to its parent.

## Build

```bash
wasm-pack build crates/arora-web --target web --dev
```

Output lands under `crates/arora-web/pkg/`. To consume from a bundler
swap `--target web` for `--target bundler`.

## Integration test (headless browser)

```bash
# First, force a wasm32-wasip1 build of the test guest module:
cargo test -p arora-integration-tests

# Then run the wasm-bindgen-test in a headless browser:
GECKODRIVER=$(which geckodriver) wasm-pack test --headless --firefox crates/arora-web
# (or --chrome — see notes below)
```

The test loads `test-rust-wasm.wasm` through `Engine.loadModule` and
calls `ping`, asserting the round-trip works. The others drive
`AroraRuntime`'s operations on a device nobody steps, one the page steps, and
one `run()` owns, counting the steps each one takes.

> **Browser pick:** `wasm-pack` downloads a pinned `chromedriver`; if it
> doesn't match the locally installed Chrome it 404s. Firefox /
> geckodriver is more forgiving; CI uses `--firefox`.
>
> **Apple Silicon:** the `geckodriver` wasm-pack auto-downloads is
> x86_64 and SIGABRTs under Rosetta with a "rosetta error: Attachment
> of code signature supplement failed" message. Install a native arm64
> driver (`brew install geckodriver`) and point at it via the
> `GECKODRIVER` env var (as shown above). Same idea for `chromedriver`
> via the `CHROMEDRIVER` env var.

## Demo pages

```bash
crates/arora-web/www/serve.sh
# open http://localhost:8080          – Engine call demo (ping, succeed, cos)
# open http://localhost:8080/demo.html – BehaviorTreeRunner tick demo
```

`serve.sh` runs `wasm-pack build`, stages guest modules under
`www/modules/<name>/`, then starts `python3 -m http.server`.

`demo.html` shows a live behavior tree: each tick increments `x` by 0.1
via `add()`, then computes `cos(x)`. Variables persist across ticks.
The SVG tree panel highlights node status; the side panel displays
variable values and a tick log.

## Why a separate crate

`arora` is dual-target now (`wasmtime-host` and `native-host` features
default-on for native; gated out on wasm32, where the browser executor
takes over). `arora-web` exists purely to attach the wasm-bindgen
surface and keep host builds free of `wasm-bindgen` deps.