@pocketactors/ax-browser
v0.0.0-dev.3
Published
ax for the browser — the actor state-machine engine, in WebAssembly.
Readme
@pocketactors/ax — the browser binding
Run ax actors in the browser, from a tiny ax.wasm that is just the ax core.
A machine written for the Node binding runs here unchanged:
same System, same ActorRef, same actor handle, for every method the core
can back. The runtime underneath is the freestanding ax core in WebAssembly,
with no libuv, no sockets, and no journal, but the surface a machine touches is
identical.
The binding imposes none of the limits the core itself does without. A machine has as many states and transitions as you write; a system holds as many actors as memory allows; a pid carries its full generation, so a reference to a dead actor dead-letters instead of reaching whoever took its slot; names and state ids grow to any length; and memory grows to the wasm32 ceiling unless the host caps it. Snapshot, machine inspection, and the invariant check sit alongside the actor surface. What is absent is only what needs a runtime backend the browser core has not been given — durability and the peer.
This binding folds the prototype that lived in examples/multiplayer-editor into
a first-class binding, a sibling of bindings/node and bindings/python. It is
literate: the markdown is the source of truth and the .c/.js are generated.
| Document | Generates | What it is |
|----------|-----------|------------|
| ax-wasm.md | ax-wasm.c | the C facade: trampolines into JS, the grant-and-grow seam, the growable staging arena |
| ax-browser.md | ax-browser.js | the JS binding — System, ActorRef, Actor |
| build.md | dist/ax.wasm | the recipe that links the core-only module and reports its size |
The API — the same as the Node binding
The browser binding wraps the core, so it implements every method the core
can back, named and shaped as in bindings/node/axr.js.
import { System } from './ax-browser.js';
const sys = await System.create('/ax.wasm'); // fetch + instantiate + open
sys.plug('dom', (target, event, payload) => paint(target, payload));
sys.define({
name: 'counter',
data: () => ({ n: 0 }),
states: {
idle: {
entry: (a) => a.emit('dom', '#count', 'set', { n: a.data.n }),
on: {
inc: (a, ev) => { a.data.n += ev.by ?? 1; a.emit('dom', '#count', 'set', { n: a.data.n }); },
total: (a) => a.reply('total', { n: a.data.n }), // answers a call
},
},
},
});
const c = sys.spawn('counter'); // -> ActorRef
c.send('inc', { by: 2 }); // post + pump
const { n } = await c.call('total'); // request/replySystem
define(spec)—specis{ name, data, initial, states };states[id]is{ on, entry, exit, initial }; a transition is an actionfn, a string target, or{ target, when, then }.spawn(name, opts?)→ActorRef. Adurableorstoreoption throws (see the gaps below).post(pid, event, data, { via, reply }?),send(name, event, data)(by registered name),call(target, event, data, { timeout }?)→Promise.register(name, pid),whereis(name),list(),has(name)— a JS-side name registry, since the core keeps none, matching the Node semantics.plug(name, emit)orplug({ name, emit }).observe(observer),tick(now),due(),run()/drive(),kill(pid),close().snapshot(refOrPid)→ the actor's active leaf-state ids;verify()→ the core's invariant check (truewhen sound);inspect(name)→ a machine's compiled shape,{ name, states }.
ActorRef (from spawn)
send(event, data, opts?), call(event, data, opts?), kill(), and .pid,
.spec, .idx, .gen.
the actor handle (a handler receives (actor, event))
actor.data (a JS object held JS-side, keyed by pid), actor.send(toPidOrName,
event, data, { delay }?), actor.emit(port, target, event, data),
actor.reply(event, data), actor.forward(toPidOrName) (hand the current event
on, keeping its return address — choreography), actor.after(name, ms, data?),
actor.cancel(name),
actor.raise(event, data), actor.child(id), actor.in(stateId),
actor.self, actor.parent.
call/reply is request/reply over a hidden one-shot reply port: call posts
the event stamped with a reply address, the actor's actor.reply emits to it,
and the host resolves the Promise with the reply data.
The memory model — a growable WebAssembly.Memory
ax.wasm is tiny and just the ax core. The host creates a small
WebAssembly.Memory (8 pages to start, no fixed maximum) and imports it; the
module owns the grant and grows the memory itself, to the wasm32 ceiling or to a
maxPages cap the host opts into at create.
- The facade's zero-argument
ax_open(void)carves the core's grant from&__heap_baseto the current end of linear memory, and wires the grow callback to__builtin_wasm_memory_grow, which extends linear memory a page at a time and returns the new span —NULLat the host's ceiling, so the core reportsAX_NOMEM. There is no JS-side grant and no JS import for growth. - Strings cross through a facade-owned staging arena:
axw_inbuf(len)returns a pointer the JS writes a UTF-8 C string into, and the facade interns it into a growable arena sized to the string, so no fixed buffer caps a name's length. JS never carves scratch out of the core's region. - Payloads and handlers stay JS-side, keyed by id; the wasm holds only the configuration. The payload store is cleared after each pumped post.
This follows minislack's model from
examples/minislack/wasm/glue.c and the
wasm platform seam. A grown page is
the engine's for the instance's life, so a cached buffer view is re-read after
every call that may allocate — memory.grow detaches it.
Building
From the repository root:
mdc compile bindings/browser/build.mdThat tangles the core (ax.c), the bulk-memory floor (axr-wasm-mem.c), and the
facade (ax-wasm.c), links them with clang into dist/ax.wasm, validates it,
tangles ax-browser.js, and prints the byte size. The link is core-only: no
axr, no axlog. It is built -Oz with --gc-sections and --strip-all, so
the module stays small, about 26 KB.
What is here, and what is not
The binding backs every method the core can back, with no cap the core itself does not impose. What it leaves out is the axr runtime, whose features each need a host backend the browser core does not carry:
- Durability —
store,durable,evict,peek. Adurable/storespawn throws a clear error rather than silently doing nothing. - Tracing and metrics — the observe-driven span engine and the Prometheus formatter.
- The peer /
TcpTransport— cross-runtime addressing and the wire.
These are axr (runtime) features, absent by construction. The binding exports
{ System, Actor, ActorRef }, the Node names minus TcpTransport.
