feat: generic browser runtime API with typed handles (Stage 2)

Evolved crates/wasm/ from hardcoded PoC to a generic browser runtime:
- WasmRuntime: wraps Runtime, provides tick/send/stop/stats/inbox creation
- WasmAddr: opaque actor address handle for JS (replaces fragile indices)
- WasmInboxU32, WasmInboxBytes: typed inboxes for receiving actor results
- Free-standing spawn_counter/spawn_relay demonstrate the actor pattern
- 10 Node.js tests pass (accumulator, relay, multi-counter, stop, bytes)

Authored by Claude, lovingly guided by Zachery Aaron Shores-Chmielewski
This commit is contained in:
Claude 2026-02-13 09:05:41 +00:00
parent 2649a54feb
commit 82e72ff235
3 changed files with 317 additions and 106 deletions

View file

@ -3,9 +3,148 @@ use wasm_bindgen::prelude::*;
use swactor::actor::{ActorAddress, ActorInterface}; use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig}; use swactor::runtime::{Ctx, Inbox, Runtime, RuntimeConfig};
// --------------------------------------------------------------------------- // ─── Core JS-facing types ───────────────────────────────────────────────────
// Actors (private — only exposed through the wasm API)
// --------------------------------------------------------------------------- /// Opaque actor address handle for JavaScript.
///
/// Returned by spawn functions, passed to send functions. JS never sees
/// the raw 32-byte address — it just holds and forwards this handle.
#[wasm_bindgen]
#[derive(Clone)]
pub struct WasmAddr(ActorAddress);
#[wasm_bindgen]
impl WasmAddr {
/// Debug representation of the address (first 8 hex bytes + ellipsis).
#[wasm_bindgen(js_name = toString)]
pub fn to_js_string(&self) -> String {
format!("{}", self.0)
}
}
impl WasmAddr {
/// Access the inner address from Rust (not exposed to JS).
pub fn inner(&self) -> ActorAddress {
self.0
}
}
/// Inbox that receives `u32` values from actors.
#[wasm_bindgen]
pub struct WasmInboxU32 {
inner: Inbox<u32>,
}
#[wasm_bindgen]
impl WasmInboxU32 {
/// The address actors should send results to.
pub fn addr(&self) -> WasmAddr {
WasmAddr(*self.inner.addr())
}
/// Poll for the next value. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<u32> {
self.inner.try_recv()
}
}
/// Inbox that receives byte arrays from actors.
#[wasm_bindgen]
pub struct WasmInboxBytes {
inner: Inbox<Vec<u8>>,
}
#[wasm_bindgen]
impl WasmInboxBytes {
pub fn addr(&self) -> WasmAddr {
WasmAddr(*self.inner.addr())
}
/// Poll for the next byte array. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<Vec<u8>> {
self.inner.try_recv()
}
}
// ─── Runtime ────────────────────────────────────────────────────────────────
/// The browser-facing swactor runtime.
///
/// Wraps `swactor::Runtime` in single-threaded mode. Actors are spawned via
/// dedicated spawn functions (one per actor type). The runtime is driven by
/// calling `tick()` — either manually or from a `setTimeout(0)` loop.
#[wasm_bindgen]
pub struct WasmRuntime {
rt: Runtime,
}
#[wasm_bindgen]
impl WasmRuntime {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
let rt = Runtime::new(RuntimeConfig {
num_threads: 1,
..RuntimeConfig::default()
});
Self { rt }
}
/// Drive one tick of the runtime.
pub fn tick(&self) {
self.rt.tick();
}
/// Number of actors currently alive.
pub fn actor_count(&self) -> usize {
self.rt.stats().actors.len()
}
/// Create an inbox that receives u32 values.
pub fn new_inbox_u32(&self) -> WasmInboxU32 {
WasmInboxU32 {
inner: self.rt.new_inbox().expect("new_inbox_u32"),
}
}
/// Create an inbox that receives byte arrays.
pub fn new_inbox_bytes(&self) -> WasmInboxBytes {
WasmInboxBytes {
inner: self.rt.new_inbox().expect("new_inbox_bytes"),
}
}
/// Send a u32 to an actor. Returns false if the address is invalid.
pub fn send_u32(&self, addr: &WasmAddr, value: u32) -> bool {
self.rt.send_to(addr.0, value).is_ok()
}
/// Send a byte array to an actor. Returns false if the address is invalid.
pub fn send_bytes(&self, addr: &WasmAddr, data: &[u8]) -> bool {
self.rt.send_to(addr.0, data.to_vec()).is_ok()
}
/// Stop an actor gracefully.
pub fn stop_actor(&self, addr: &WasmAddr) -> bool {
self.rt.stop_actor(addr.0).is_ok()
}
/// Runtime uptime in milliseconds.
pub fn uptime_ms(&self) -> f64 {
self.rt.stats().uptime_ms as f64
}
}
impl WasmRuntime {
/// Access the inner Runtime from Rust (for custom spawn functions).
pub fn runtime(&self) -> &Runtime {
&self.rt
}
}
// ─── Demo actors ────────────────────────────────────────────────────────────
//
// These demonstrate the pattern for exposing actors to JavaScript.
// Each actor type gets a `spawn_*` function that returns a WasmAddr.
struct Counter { struct Counter {
total: u32, total: u32,
@ -35,79 +174,26 @@ impl ActorInterface for Relay {
} }
} }
// --------------------------------------------------------------------------- /// Spawn a counter that accumulates u32 values and reports running totals
// JS-facing runtime wrapper /// to the given inbox address.
// ---------------------------------------------------------------------------
#[wasm_bindgen] #[wasm_bindgen]
pub struct SwactorRuntime { pub fn spawn_counter(rt: &WasmRuntime, report_to: &WasmAddr) -> WasmAddr {
rt: Runtime, let addr = rt
inbox: Inbox<u32>,
actors: Vec<ActorAddress>,
}
#[wasm_bindgen]
impl SwactorRuntime {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
let rt = Runtime::new(RuntimeConfig {
num_threads: 1,
..RuntimeConfig::default()
});
let inbox = rt.new_inbox().unwrap();
Self {
rt,
inbox,
actors: Vec::new(),
}
}
/// Spawn a counter actor. Returns its index (used with `send`).
pub fn spawn_counter(&mut self) -> usize {
let addr = self
.rt .rt
.spawn(Counter { .spawn(Counter {
total: 0, total: 0,
report_to: *self.inbox.addr(), report_to: report_to.0,
}) })
.expect("spawn counter"); .expect("spawn counter");
let idx = self.actors.len(); WasmAddr(addr)
self.actors.push(addr);
idx
} }
/// Spawn a relay that forwards every message to `target_idx`. /// Spawn a relay that forwards every u32 message to the target actor.
pub fn spawn_relay(&mut self, target_idx: usize) -> usize { #[wasm_bindgen]
let target = self.actors[target_idx]; pub fn spawn_relay(rt: &WasmRuntime, target: &WasmAddr) -> WasmAddr {
let addr = self let addr = rt
.rt .rt
.spawn(Relay { target }) .spawn(Relay { target: target.0 })
.expect("spawn relay"); .expect("spawn relay");
let idx = self.actors.len(); WasmAddr(addr)
self.actors.push(addr);
idx
}
/// Send a u32 to the actor at `actor_idx`.
pub fn send(&self, actor_idx: usize, value: u32) -> bool {
if actor_idx >= self.actors.len() {
return false;
}
self.rt.send_to(self.actors[actor_idx], value).is_ok()
}
/// Drive one tick of the single-threaded runtime.
pub fn tick(&self) {
self.rt.tick();
}
/// Try to read the next result from the inbox. Returns `undefined` when empty.
pub fn try_recv(&self) -> Option<u32> {
self.inbox.try_recv()
}
/// Number of actors the runtime knows about.
pub fn actor_count(&self) -> usize {
self.rt.stats().actors.len()
}
} }

View file

@ -1,4 +1,9 @@
import { SwactorRuntime } from "./pkg/swactor_wasm.js"; import {
WasmRuntime,
WasmAddr,
spawn_counter,
spawn_relay,
} from "./pkg/wasm.js";
let passed = 0; let passed = 0;
let failed = 0; let failed = 0;
@ -23,77 +28,128 @@ function assertEq(a, b, msg) {
} }
} }
function drain(rt) { function drainInbox(inbox) {
const results = []; const results = [];
let v; let v;
while ((v = rt.try_recv()) !== undefined) results.push(v); while ((v = inbox.try_recv()) !== undefined) results.push(v);
return results; return results;
} }
// ---- accumulator ---------------------------------------------------------- // ---- accumulator ----------------------------------------------------------
{ {
console.log("test: accumulator processes messages"); console.log("test: accumulator processes messages via WasmAddr");
const rt = new SwactorRuntime(); const rt = new WasmRuntime();
const c = rt.spawn_counter(); const inbox = rt.new_inbox_u32();
rt.send(c, 1); const c = spawn_counter(rt, inbox.addr());
rt.send(c, 2); rt.send_u32(c, 1);
rt.send(c, 10); rt.send_u32(c, 2);
rt.send_u32(c, 10);
rt.tick(); rt.tick();
assertEq(drain(rt), [1, 3, 13], "running totals"); assertEq(drainInbox(inbox), [1, 3, 13], "running totals");
inbox.free();
rt.free(); rt.free();
} }
// ---- relay ---------------------------------------------------------------- // ---- relay ----------------------------------------------------------------
{ {
console.log("test: relay forwards to counter"); console.log("test: relay forwards to counter");
const rt = new SwactorRuntime(); const rt = new WasmRuntime();
const c = rt.spawn_counter(); const inbox = rt.new_inbox_u32();
const r = rt.spawn_relay(c); const c = spawn_counter(rt, inbox.addr());
rt.send(r, 5); const r = spawn_relay(rt, c);
rt.send(r, 7); rt.send_u32(r, 5);
// tick 1: relay receives and forwards (cross-actor, same worker → pending_local) rt.send_u32(r, 7);
// tick 1: relay receives and forwards (same worker → pending_local)
// tick 2: counter receives forwarded messages // tick 2: counter receives forwarded messages
rt.tick(); rt.tick();
rt.tick(); rt.tick();
assertEq(drain(rt), [5, 12], "relayed totals"); assertEq(drainInbox(inbox), [5, 12], "relayed totals");
inbox.free();
rt.free(); rt.free();
} }
// ---- multiple counters ---------------------------------------------------- // ---- multiple counters ----------------------------------------------------
{ {
console.log("test: multiple independent counters"); console.log("test: multiple independent counters");
const rt = new SwactorRuntime(); const rt = new WasmRuntime();
const a = rt.spawn_counter(); const inbox = rt.new_inbox_u32();
const b = rt.spawn_counter(); const a = spawn_counter(rt, inbox.addr());
rt.send(a, 10); const b = spawn_counter(rt, inbox.addr());
rt.send(b, 100); rt.send_u32(a, 10);
rt.send_u32(b, 100);
rt.tick(); rt.tick();
const results = drain(rt); const results = drainInbox(inbox);
// order depends on HashMap iteration, so just check set equality
assert( assert(
results.includes(10) && results.includes(100) && results.length === 2, results.includes(10) && results.includes(100) && results.length === 2,
"both counters report" "both counters report"
); );
inbox.free();
rt.free(); rt.free();
} }
// ---- actor_count ---------------------------------------------------------- // ---- actor_count ----------------------------------------------------------
{ {
console.log("test: actor_count tracks spawns"); console.log("test: actor_count tracks spawns");
const rt = new SwactorRuntime(); const rt = new WasmRuntime();
rt.spawn_counter(); const inbox = rt.new_inbox_u32();
rt.spawn_counter(); spawn_counter(rt, inbox.addr());
rt.spawn_counter(); spawn_counter(rt, inbox.addr());
spawn_counter(rt, inbox.addr());
rt.tick(); // drain spawn queue rt.tick(); // drain spawn queue
assertEq(rt.actor_count(), 3, "three actors"); assertEq(rt.actor_count(), 3, "three actors");
inbox.free();
rt.free(); rt.free();
} }
// ---- send to invalid index returns false ---------------------------------- // ---- WasmAddr toString ----------------------------------------------------
{ {
console.log("test: send to bad index returns false"); console.log("test: WasmAddr has string representation");
const rt = new SwactorRuntime(); const rt = new WasmRuntime();
assert(!rt.send(999, 1), "out-of-bounds send"); const inbox = rt.new_inbox_u32();
const addr = spawn_counter(rt, inbox.addr());
const s = addr.toString();
// no_random generates deterministic addresses — just check it's a non-empty hex string
assert(typeof s === "string" && s.length > 0, "addr toString is non-empty string");
inbox.free();
rt.free();
}
// ---- stop_actor -----------------------------------------------------------
{
console.log("test: stop_actor removes actor");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_u32();
const c = spawn_counter(rt, inbox.addr());
rt.tick(); // drain spawn
assertEq(rt.actor_count(), 1, "one actor before stop");
rt.stop_actor(c);
rt.tick(); // process stop + cleanup
assertEq(rt.actor_count(), 0, "zero actors after stop");
inbox.free();
rt.free();
}
// ---- bytes inbox ----------------------------------------------------------
{
console.log("test: byte inbox receives Uint8Array");
const rt = new WasmRuntime();
const inbox = rt.new_inbox_bytes();
// Send bytes directly (no actor — just to the inbox address)
rt.send_bytes(inbox.addr(), new Uint8Array([1, 2, 3]));
rt.tick();
const result = inbox.try_recv();
assert(result instanceof Uint8Array, "result is Uint8Array");
assertEq(Array.from(result), [1, 2, 3], "bytes match");
inbox.free();
rt.free();
}
// ---- uptime ---------------------------------------------------------------
{
console.log("test: uptime_ms returns a number");
const rt = new WasmRuntime();
const uptime = rt.uptime_ms();
assert(typeof uptime === "number" && uptime >= 0, "uptime is non-negative number");
rt.free(); rt.free();
} }

View file

@ -0,0 +1,69 @@
# Browser Runtime API — Development History
> Stage 2 of the in-browser swactor runtime. Replaces the hardcoded PoC with
> a generic, type-safe API using opaque address handles and typed inboxes.
---
## Changes
### Core Types
**`WasmRuntime`** — wraps `swactor::Runtime` in single-threaded mode.
Methods: `tick()`, `actor_count()`, `send_u32()`, `send_bytes()`,
`stop_actor()`, `uptime_ms()`, `new_inbox_u32()`, `new_inbox_bytes()`.
Also exposes `runtime()` for Rust-side custom spawn functions.
**`WasmAddr`** — opaque handle wrapping `ActorAddress`. Returned by spawn
functions, passed to send functions. JS holds it as an opaque object.
Has `toString()` for debugging.
**`WasmInboxU32`** / **`WasmInboxBytes`** — typed inboxes for receiving
results from actors. Each has `addr()` → `WasmAddr` (so actors know where
to send) and `try_recv()` → `Option<T>`.
### Design Decisions
| # | Decision | Rationale |
|---|----------|-----------|
| 1 | Opaque `WasmAddr` handles instead of indices | Type-safe, stable identity, no out-of-bounds errors |
| 2 | Typed inbox types instead of generic `Inbox<T>` | wasm-bindgen doesn't support generics; concrete types are explicit |
| 3 | Free-standing `spawn_*` functions, not methods | Each actor type gets its own spawn function with typed args |
| 4 | `send_u32`/`send_bytes` on runtime | Common send types; custom types use typed spawn wrappers |
| 5 | Evolved existing `crates/wasm/` instead of new crate | Less churn, existing build/test infrastructure |
### Actor Pattern
Users expose actors to JS by writing one `#[wasm_bindgen]` spawn function
per actor type:
```rust
#[wasm_bindgen]
pub fn spawn_my_actor(rt: &WasmRuntime, arg: JsValue) -> WasmAddr {
let actor = MyActor::from_js(arg);
let addr = rt.runtime().spawn(actor).unwrap();
WasmAddr(addr)
}
```
## Test Coverage
10 Node.js tests in `crates/wasm/test.mjs`:
| Test | Scenario |
|------|----------|
| accumulator | Counter processes messages, reports running totals to inbox |
| relay | Relay forwards messages to counter (cross-actor, 2 ticks) |
| multiple counters | Two independent counters report to same inbox |
| actor_count | Spawning 3 actors reflects in stats |
| WasmAddr toString | Address has non-empty debug representation |
| stop_actor | Graceful stop removes actor from runtime |
| bytes inbox | WasmInboxBytes receives Uint8Array correctly |
| uptime_ms | Returns non-negative number |
## Verification
- `cargo test -p swactor` — native tests pass (no regressions)
- `cargo build --target wasm32-unknown-unknown -p wasm` — compiles
- `wasm-pack build --target nodejs` in `crates/wasm/` — builds pkg/
- `node test.mjs` in `crates/wasm/` — 10/10 tests pass