Browser and JavaScript
The same core that runs on servers, compiled to WebAssembly and hosted in a worker. This page shows how to build the two npm packages, start an actor system, talk to actors, read their journals, keep them in OPFS and connect them to server actors.
Two packages
The JavaScript bindings live in bindings/js, an npm workspace with two packages. The Rust side is the crate crates/vertep-wasm.
| Package | What it holds |
|---|---|
@vertep/web | The typed client (createActorSystem, ActorRef, VertepError), the worker entry @vertep/web/worker, and two WebAssembly builds: core (core, shell, the null, CEL and rust datamodels, an in-memory store) and journal (the same plus tiramemsu on SQLite in WebAssembly). |
@vertep/dm-ecma | A third WebAssembly build with the boa ECMAScript engine, exported as ecmascript. It is separate because boa does not fit the main package's size budget. |
Both packages are ES modules, both are at version 0.1.0, and both need Node.js 22 or later. A release script, bindings/js/scripts/check-version.mjs, fails when a package version differs from the Rust workspace version.
Install both from npm: npm install @vertep/web, plus @vertep/dm-ecma for charts on the ECMAScript datamodel. Build them from source, as below, only to work on the bindings themselves.
Building from source
You need the Rust toolchain pinned in rust-toolchain.toml (it already lists the wasm32-unknown-unknown target), Node.js 22 or later, and the wasm-bindgen CLI at exactly the version crates/vertep-wasm/Cargo.toml pins, which is 0.2.128 today. The build script stops if the two versions differ.
cargo install wasm-bindgen-cli --version 0.2.128
cd bindings/js
npm install
npm run build # scripts/build.sh: the WebAssembly builds, the native helper, vt, then tsc
npm test # node --test over packages/web/test/
npm run check:size # the bundle size budget
scripts/build.sh builds vertep-wasm four times with different features: core and journal into packages/web/wasm/, journal,ecmascript into packages/dm-ecma/wasm/, and a test-only build with the conformance runner into .native/wasm-test, which is never published. It also builds the native helper vertep-wasm-native and vt into bindings/js/.native/, because the tests compare WebAssembly results with native ones. Set VERTEP_PROFILE=dev for a fast unoptimised build, and VERTEP_SKIP_ECMA=1 to skip the ECMAScript and test builds.
To use the packages in another project before they are published, pack them and install the tarball: npm pack -w @vertep/web, then npm install ./vertep-web-0.1.0.tgz. The package test packages/web/test/package.test.mjs does exactly this and runs a small program from the installed copy.
One worker holds everything
The whole actor system runs in one dedicated worker: a Web Worker in a browser, a worker_threads worker in Node.js. The page keeps only a thin client. Every client call returns a Promise or delivers results through a subscription, so nothing blocks the main thread.
Why one worker for all of it? A step calls guards and actions synchronously, and tiramemsu's WebAssembly host is synchronous too, with an OPFS file system that needs the synchronous access handles only a dedicated worker has. So the core, the shell, the journal and your guard and action functions must share one thread.
The shell maps the runtime's three needs onto the JavaScript event loop: spawn uses wasm_bindgen_futures::spawn_local, sleep is a setTimeout future, and the clock is Date.now(), read only when a journal entry is stamped. Wall-clock time reaches a chart only as a journaled timestamp, just as on a server.
A first run in Node.js
This program uses the built package from the repository. The chart is the CEL counter from packages/web/test/helpers.mjs; CEL charts need no implementations module.
import { createActorSystem, VertepError } from "@vertep/web";
const COUNTER = `<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="idle" datamodel="cel">
<datamodel><data id="n" expr="0"/></datamodel>
<state id="idle">
<transition event="inc"><assign location="ctx.n" expr="ctx.n + 1"/></transition>
<transition event="add"><assign location="ctx.n" expr="ctx.n + ev.data.by"/></transition>
<transition event="go" target="busy"/>
</state>
<state id="busy"><transition event="done" target="idle"/></state>
</scxml>`;
const system = await createActorSystem({ storage: "memory", journal: true });
console.log("build:", system.build);
const counter = system.virtual({ source: COUNTER, format: "scxml", type: "https://example.org/charts/counter" }, "c1");
counter.subscribe((s) => console.log("snapshot", s.position, s.configuration, s.context));
await new Promise((r) => setTimeout(r, 20));
await counter.send({ type: "inc" });
await counter.send({ type: "add", by: 4 });
await counter.send({ type: "go" });
console.log("can done?", await counter.can({ type: "done" }));
const then = await counter.asOf({ position: 1 });
console.log("as of position 1:", then.configuration, then.context);
for (const e of await counter.journal()) console.log("journal", e.position, e.kind, e.event?.name);
console.log("address:", await counter.address);
try {
await system.spawn(`<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="a"><state id="a"><transition event="x" target="missing"/></state></scxml>`);
} catch (e) {
console.log(e instanceof VertepError, e.code, e.violations[0]);
}
await system.terminate();
build: journal
snapshot 1 [ 'idle' ] { n: 1 }
snapshot 2 [ 'idle' ] { n: 5 }
snapshot 3 [ 'busy' ] { n: 5 }
can done? true
as of position 1: [ 'idle' ] { n: 1 }
journal 1 input inc
journal 2 input add
journal 3 input go
address: https://example.org/charts/counter/c1
true ChartInvalid {
column: 88,
line: 1,
message: 'transition target names unknown state `missing`'
}
The subscription is set up a moment before the first send because subscribe registers with the worker asynchronously; the package's own tests do the same. The virtual actor's address is the chart type IRI followed by the key.
Creating an actor system
createActorSystem(options) starts the worker and resolves once its store is open. The options are the SystemOptions interface in packages/web/src/client.ts.
| Option | Meaning |
|---|---|
storage | "opfs" (durable) or "memory" (volatile). Required: there is no default, and no fallback from one to the other. Anything else rejects with InvalidArgument. |
path | The store path, which is the tiramemsu file name. |
journal | For memory: keep a tiramemsu journal that can be exported. opfs always keeps one. |
import | A journal file (Uint8Array) to start from; use it with journal: true. |
charts | Charts to register before the first scheduling round. That round recovers overdue durable timers, so a chart must be registered here for its actors' overdue timers to fire (once) after a reopen. |
implementations | URL of a module whose guards and actions exports become named functions. The worker imports it. |
dev, onImpurity | Development mode: after each new entry the journal is replayed and compared with the live state; a mismatch is reported to onImpurity (or console.warn). |
idleTimeoutMs | Idle deactivation of virtual actors. |
datamodel | Another WebAssembly build, such as ecmascript from @vertep/dm-ecma. |
transports | { websocket: {...} }, the transport to server actors; see below. |
workerUrl | The worker entry; the bundled worker.js by default. |
The worker loads the smallest build the options allow. A plain memory store loads core, which has no SQLite. A journaled store (opfs, journal: true or import) loads journal. system.build tells you which one it got: "core", "journal" or "custom". system.terminate() stops the worker; later calls reject with WorkerTerminated.
Actors from SCXML, YAML or RDF
A chart source is validated and content-addressed exactly as it is natively, before any actor runs it. An invalid chart rejects with ChartInvalid, and error.violations lists every problem with its line and column.
| Call | What it does |
|---|---|
system.spawn(chart, {format, type, key}) | Starts an actor from a chart source and resolves to an ActorRef. |
system.register(chart, {format, type}) | Registers a chart under a type IRI (its version IRI by default) and resolves to {type, version}. |
system.virtual(chart or type, key) | The virtual actor of that type and key. It is activated by its first message; every later call with the same type and key reaches the same actor. |
system.actor(address) | A reference to any actor by address IRI, local or on a server. |
system.actors() | Every actor with a journal. |
format is "scxml", "yaml" or "rdf". The worker also accepts turtle, ntriples, nquads and trig, and guesses the format from the first characters of the source when none is given (crates/vertep-wasm/src/charts.rs). A chart can be passed as a plain string or as a ChartSpec object, {source, format, type}, which is what vt gen ts generates.
// from packages/web/test/api.test.mjs
await system.register(ORDER, { type: ORDER_TYPE });
const a = system.virtual(ORDER_TYPE, "k1");
assert.deepEqual(await system.actors(), []); // nothing journaled before the first message
await a.send({ type: "pay" });
const b = system.virtual(ORDER_TYPE, "k1");
assert.equal(await b.address, await a.address);
Before its first message a virtual actor has no journal, so getSnapshot reports the start configuration at position 0. Entry one carries the start macrostep together with the first event.
Sending and requesting
An event is a plain object: its name in type, and its payload's fields beside it, as in { type: "add", by: 4 }. A payload that is not a record goes in data: { type: "count", data: 3 }. Inside the chart the payload is ev.data as usual.
actor.send(event, {messageId})resolves to{messageId}. Every send carries a message id, a freshurn:uuid:one by default. Pass your own to make a retry safe: a second send with the same id is processed once and journaled once.actor.request(event, {timeoutMs})resolves with the chart'svt:replyevent. It rejects withRequestTimeoutaftertimeoutMs(30,000 by default), and a late reply is dropped.
<!-- the PRICER chart from packages/web/test/helpers.mjs -->
<state id="s">
<transition event="price.get">
<send event="vt:reply" targetexpr="ev.origin"><content expr="{'price': 42}"/></send>
</transition>
</state>
const reply = await pricer.request({ type: "price.get" }, { timeoutMs: 2000 });
reply.name; // "vt:reply"
reply.data; // { price: 42 }
request is the promise-based ask for code outside the actor system. Charts keep using the non-blocking vt:request.
Snapshots and subscriptions
A Snapshot has address, position (the journal position it follows), version (the chart version IRI), configuration (active state ids in document order), context, terminated, and matches(id). The API borrows XState's words, but the semantics are SCXML's, and there is no mode that changes them.
| Call | What it gives |
|---|---|
actor.getSnapshot() | The current snapshot. |
actor.subscribe(f) | One snapshot per macrostep that changed the configuration or the context. Returns an unsubscribe function. |
actor.onTransition(f) | Per macrostep: the triggering event and the transitions taken, each as {source, targets, iri}. |
actor.can(event) | Whether the actor accepts the event now: it enables a transition, or an active state defers it. Nothing changes and nothing is journaled. |
snapshot.matches(id) | Whether state id is active. It checks a state id, not a dotted path. |
Notifications arrive in journal order, at most once per macrostep, and never show a microstep. The transitions in a notification are the ones the journal entry recorded, which the runtime sorts by node IRI; the order they ran in across microsteps is not recorded yet.
Journal, time travel and SPARQL
Every actor's history is its journal, and the browser journal has the same format as the native one.
actor.journal({from})lists entries:position,kind(input,migrationorfork),version,messageId,timestamp,digest, and theevent,transitionsandtimerwhen present.actor.asOf({position})oractor.asOf({time})returns the snapshot after a past position or as of a transaction time. It is read-only.system.query(sparql, {time})runs SPARQL on the graph store, now or as believed attime(epoch milliseconds). Results are{variables, rows},{boolean}or{triples}. Queries of the current state work on any store; a query withtimeneeds a journaled store, and the plain in-memory store rejects it because it keeps no history.system.exportJournal()returns the journal file as bytes, to download or to open with the native runtime.
This program follows the ORDER chart from the tests, whose pay transition arms a 300 ms timer that moves it on to shipped:
const system = await createActorSystem({ storage: "memory", journal: true });
const order = await system.spawn(ORDER);
const addr = await order.address;
await order.send({ type: "pay" });
const afterPay = Date.now();
await new Promise((r) => setTimeout(r, 500));
const q = `SELECT ?s WHERE { GRAPH <${addr}> { <${addr}> <https://w3id.org/vertep/vt#inState> ?s } }`;
console.log("now:", (await system.query(q)).rows);
console.log("as of afterPay:", (await system.query(q, { time: afterPay })).rows);
const bytes = await system.exportJournal();
console.log("journal file:", bytes.length, "bytes, header", JSON.stringify(new TextDecoder().decode(bytes.slice(0, 15))));
now: [
{
s: '<urn:vertep:chart:sha256:f2410655de9eca00fe4d9f4610fe8a35f37f6dbb2338b14cedf3dd518df8d345#s.shipped>'
}
]
as of afterPay: [
{
s: '<urn:vertep:chart:sha256:f2410655de9eca00fe4d9f4610fe8a35f37f6dbb2338b14cedf3dd518df8d345#s.paid>'
}
]
journal file: 229376 bytes, header "SQLite format 3"
State nodes are minted under the chart version IRI, so the answer names the exact chart that was running. See RDF and versions for the vocabulary.
Guards and actions in JavaScript
A chart with the rust datamodel names its guards and actions by IRI. In the browser those names resolve to JavaScript functions exported by an implementations module, which the worker imports at start. This is the test fixture packages/web/test/fixtures/impl.mjs:
// An implementations module: named guards and actions, imported inside the worker.
export const guards = {
"vt:guard/order/positive": ({ event }) => event.amount > 0,
};
export const actions = {
"vt:action/order/record": ({ context, event, raise }) => {
context.total = event.amount;
raise({ type: "receipt", amount: event.amount });
},
};
const system = await createActorSystem({
storage: "memory",
implementations: new URL("./fixtures/impl.mjs", import.meta.url),
});
A guard receives {context, event}. An action receives {context, event, raise, send}, may change context in place or return a new one, and may raise internal events or send with {target, delay}. Guards must be pure. Unlike in Rust, nothing in the language helps you here, so turn on dev: true while developing: impure functions then show up as replay mismatches. For charts that a language model writes, prefer the CEL datamodel, which needs no module at all.
Storage: OPFS and Web Locks
Storage is chosen once, explicitly, and never changes behind your back.
opfskeeps the tiramemsu journal in the Origin Private File System. It needs OPFS synchronous access handles, which exist only in a browser's dedicated worker. Where they are missing (in Node.js, for example)createActorSystemrejects withStorageUnavailable, and no memory store is opened in its place.memoryis volatile. Withjournal: trueit still keeps a tiramemsu journal, which you can export and later pass back asimport. This is how Node.js persists.
Before opening an OPFS store the worker takes a Web Lock named vertep:store:<path> with ifAvailable. A second tab or worker that opens the same store gets StoreLocked at once instead of waiting or corrupting the file, because tiramemsu allows one writer per file. The lock is released when the holding worker terminates. The same helper is exported as takeStoreLock(path) and lockName(path).
A journal file moves between the browser and a server unchanged: the storage tests export a journal from WebAssembly, replay it natively, and import a native one back, with identical snapshots.
// from packages/web/test/storage.test.mjs: persist through export
const first = await createActorSystem({ storage: "memory", journal: true });
const a = first.virtual({ source: COUNTER, format: "scxml", type: ORDER_TYPE }, "p");
await a.send({ type: "add", by: 2 });
const file = await first.exportJournal();
await first.terminate();
const later = await createActorSystem({
storage: "memory", journal: true, import: file,
charts: [{ source: COUNTER, type: ORDER_TYPE }],
});
const b = later.virtual(ORDER_TYPE, "p"); // same state, and it resumes
Errors
Every rejection is a VertepError with a stable code. The list is shared with the Rust side in crates/vertep-wasm/src/error.rs.
| Code | When |
|---|---|
ChartInvalid | A chart source failed to read or validate; violations holds each problem with line, column and, when known, path. |
StorageUnavailable | The requested storage does not exist here (OPFS outside a dedicated worker, no Web Locks, or a build without the journal). |
StoreLocked | Another worker or tab holds the store. |
RequestTimeout | No reply within a request's timeout. |
WorkerTerminated | The worker died or was terminated; raised on the client side only. |
TransportRefused | The WebSocket server refused the connection credential. |
NotFound | A call named an actor, chart type or subscription that does not exist. |
InvalidArgument | Malformed arguments, such as an event without a type. |
Activation | An actor could not be activated or replayed, for example an ecmascript chart in a build without boa. |
Delivery | A message could not be delivered. |
Store | The store failed. |
Talking to server actors over WebSocket
Browser and Node.js actors reach server actors by IRI through a WebSocket transport. It is registered as an SCXML Event I/O Processor, so a chart sends with type="websocket", and events from the server arrive with _event.origintype set to https://w3id.org/vertep/vt#WebSocketEventProcessor. The server end is in vertep-runtime behind the websocket feature (crates/vertep-runtime/src/websocket/server.rs).
<!-- the CLIENT chart from packages/web/test/helpers.mjs -->
<state id="c">
<transition event="go"><send type="websocket" event="ping" target="https://server.example/charts/server/s1"/></transition>
<transition event="pong"><assign location="ctx.n" expr="ctx.n + 1"/><assign location="ctx.ot" expr="ev.origintype"/></transition>
</state>
// from packages/web/test/websocket.test.mjs
const system = await createActorSystem({
storage: "memory",
charts: [chart],
transports: {
websocket: { url, credentials: async () => credential, targets: ["https://server.example/"], reconnectMs: 50 },
},
});
| Field | Meaning |
|---|---|
url | The server endpoint, ws: or wss:. |
credentials | A provider called before every connection. It stays on the main thread; the worker asks for its value, which goes in the hello frame. |
targets | IRI prefixes of the server's actors. Without it, every address not hosted locally goes to the server. |
hosts | IRI prefixes of this system's actors, announced to the server. |
node | This system's node IRI; a fresh one by default. |
reconnectMs | The first reconnect delay, doubling up to 5 s; 200 by default. |
The frames are JSON text: hello, then welcome or refused, then message (an envelope with message id, target, origin, event name and payload) and ack. Delivery is at least once: an actor's send stays owed in its outbox until the peer's ack, is sent again after a reconnect, and is dropped as a duplicate by message id at the receiver. The end-to-end test forces dropped acknowledgements and duplicated frames, and checks that each ping and pong is journaled once.
A JavaScript send to a server actor resolves when the server acknowledges it, and rejects with TransportRefused when the credential is refused.
request to a server actor is not supported yet, according to the implementation notes in tg.md/views/browser.md; use send and a reply event. DID-authenticated envelopes from the federation layer are planned to wrap the same frames later; today the credential provider is the only authentication.
Generated TypeScript types
vt gen ts <chart> [--name N] prints a TypeScript module from the chart's IR types: the event union with typed payloads, the context type, the state-id union, the chart as a typed ChartSpec, and a typed create<Name>Actor. It is one more projection of the same type language that gives JSON Schema, SHACL and Rust (see Datamodels and types). Run on the YAML fixture bindings/js/packages/web/test/fixtures/order.yaml:
cargo run -q --release -p vertep-cli -- gen ts bindings/js/packages/web/test/fixtures/order.yaml
// Generated by `vt gen ts` from chart urn:vertep:chart:sha256:492380086576395e03d1d5ce904e3f4f788896a3b442f6c760a6bc95374e1187. Do not edit.
import type { ActorRef, ActorSystem, ChartSpec, JsonValue } from "@vertep/web";
export type Pay = { amount: number | string };
export type OrderEvent =
| { type: "start" }
| { type: "pay" } & Pay
| { type: "authorized" }
| { type: "cancel" };
export interface OrderContext {
total: number | string;
}
export type OrderStateId = "idle" | "checkout" | "cart" | "payment" | "review" | "paid" | "cancelled";
/** The chart, typed: pass it to `spawn` or `virtual`. */
export const orderChart: ChartSpec<OrderEvent, OrderContext, OrderStateId> = {
source: "vertep: 1\nname: order\ntypes: { Pay: { amount: decimal } }\nevents: { start: null, pay: Pay, authorized: null, cancel: null }\ncontext:\n total: { type: decimal, init: \"0\" }\nstates:\n idle: { on: { start: checkout } }\n checkout:\n on: { cancel: cancelled }\n states:\n cart:\n on:\n pay: [ { cond: \"ev.data.amount <= 0\", target: cart }, { target: payment, actions: [ { assign: { location: ctx.total, expr: \"ev.data.amount\" } } ] } ]\n payment:\n on:\n authorized:\n - { cond: \"ctx.total > 100\", target: review }\n - { target: paid }\n review: { on: { approve: paid } }\n paid: { type: final }\n cancelled: { type: final }\n",
format: "yaml",
type: "urn:vertep:chart:sha256:492380086576395e03d1d5ce904e3f4f788896a3b442f6c760a6bc95374e1187",
};
/** The virtual actor of this chart with `key`, typed. */
export function createOrderActor(system: ActorSystem, key: string): ActorRef<OrderEvent, OrderContext, OrderStateId> {
return system.virtual(orderChart, key);
}
With the generated module, createOrderActor(system, "o1").send({ type: "refund" }) is a compile error, and so are a wrong payload type, a missing or extra field, and matches("nowhere"). The test packages/web/test/types.test.mjs checks seven such errors with the TypeScript compiler, and a property test checks that the TypeScript and JSON Schema projections accept the same values. A decimal becomes number | string. A chart with no declared types falls back to { type: string; [field: string]: JsonValue } and JsonValue for the context.
The ECMAScript datamodel
Charts with datamodel="ecmascript" need the boa engine, which ships in @vertep/dm-ecma. Pass its build to the system:
import { createActorSystem } from "@vertep/web";
import { ecmascript } from "@vertep/dm-ecma";
const system = await createActorSystem({ storage: "memory", datamodel: ecmascript });
Without it, spawning an ECMAScript chart rejects with Activation. The ecmascript build includes the journal, so it serves both storage kinds.
Bundle size
The main package has a size budget, measured with gzip level 9 over the WebAssembly, its glue and the client and worker JavaScript. npm run check:size fails when a build exceeds its budget. The core budget was 600 KB at first; the SPARQL engine that the graph store needs is 637 KB on its own, so the budget was raised to 1.2 MB rather than dropping SPARQL from the browser.
core: 1159.7 KB gzip (budget 1200 KB)
journal: 2198.1 KB gzip (budget 2500 KB)
| Build | Contents | Gzip | Budget |
|---|---|---|---|
core | core, shell, null/CEL/rust datamodels, in-memory store, SPARQL | 1,159.7 KB | 1,200 KB |
journal | the above plus tiramemsu on SQLite | 2,198.1 KB | 2,500 KB |
@vertep/dm-ecma | journal build plus boa (WebAssembly file alone) | 3,182.2 KB | none |
The journal build is loaded only when a journaled store opens, so a page that uses volatile memory storage downloads the core build alone. The sizes above were measured on the builds in the repository on 2026-11-12; KB means 1,000 bytes.
How the bindings are tested
- Same steps as native.
core.test.mjsruns the same charts and inputs through the WebAssembly core and the native helper and requires byte-for-byte equal traces. - Conformance.
conformance.test.mjsruns the W3C IRP suite in WebAssembly and requires the native verdicts and the same allow-list gate, and replays the differential traces against the native ones. See Verification. - Browser only.
browser.test.mjsdrives headless Chrome over the DevTools protocol for OPFS, the second-writer lock and WebSocket in a real Web Worker. It is skipped when Chrome is not installed; setCHROMEto its binary. - Packaging.
package.test.mjspacks@vertep/web, installs the tarball offline and runs a program from it, and checks the version and budget gates.