Skip to content

Phase 8 — Guest ergonomics

Phase 7 proved a component can be a supervised process. Phase 8 makes writing one feel natural — in TypeScript, Rust, or Go, over one shared wire, with a typed client that turns cross-process calls into ordinary function calls.

Why this phase

The raw actor ABI from Phase 7 works. But a guest hand-rolling send(pid, json_encode({op: "add", args: [2, 3]})) and parsing recv() bytes for a reply is friction that would keep RUSM a systems project instead of a platform. Phase 8 delivers the developer experience: export a function in TypeScript, call it from Rust or Go with full type safety, get a streaming response back — all over messages, none of the plumbing visible.

Three languages, one wire. A TypeScript service and a Go caller interoperate out of the box. The wire is JSON; the SDKs agree on the shape.

What shipped

  1. rusm-ts — a TS guest is plain TypeScript, Bun-bundled (rusm buildbun build --format=cjs), run on the shared rquickjs js-runner. A service just exports functions; a worker is export default async function. The Process API is asyncawait Process.receive() suspends the instance's fiber cheaply, no threads. Web APIs (URL, Headers, ReadableStream, console, crypto, fetch) are available, with fetch capability-gated.
  2. rquickjs + wizer pre-initialization — RUSM embeds QuickJS compiled to wasm32-wasip2 via rquickjs as the shared js-runner (~920 KB total). At build time, wizer boots the engine and the full JS bridge — all Process.*, fetch, crypto, kv primitives — and snapshots the result. Every spawned instance copy-on-write starts from that warm snapshot: the engine never boots from scratch; each spawn only evaluates the user's Bun-bundled .js. This gives roughly 8× better cold per-request throughput vs a non-pre-initialized runner, and all TS components share one ~920 KB binary.
  3. The typed clientspawn<Svc>("svc") returns a proxy whose await svc.method(args) is a real cross-process call. Generator handlers stream (for await (const x of svc.gen(...))); function arguments are callbacks that stay in the caller; svc.cast.method(...) is fire-and-forget.
  4. rusm-rs — the Rust twin: Pid/send/receive (serde JSON, same wire as TS)/spawn/registry/Stream over the actor world. A #[rusm_rs::service] macro over a mod of free functions generates a serve() dispatch loop and a typed Client with call/cast/streaming/callbacks. A Rust client and a TS service interoperate directly.
  5. rusm-go — the Go peer: TinyGo → wasm32-wasip2, idiomatic Pid/Send/Receive/Spawn, a NewService() of typed handlers, and a generic Call[R] client — all on the same JSON wire as TS and Rust. rusm new --lang go, rusm build drives TinyGo.
  6. Spawn-from-guest + monitorspawn instantiates a registered component by name from inside a guest; monitor makes a dead process arrive as a __down message. Both capability-gated: the manifest declares what a component can do; a guest can't fabricate grants.
  7. In-guest Supervisor — in both rusm-rs and rusm-ts: spawn + monitor named children and restart per strategy (one_for_one / one_for_all / rest_for_one) with max_restarts. The OTP supervision tree, written from inside a component.
  8. rusm dev watch + reload — builds, runs, and watches ./components. On a source edit it rebuilds and reloads automatically. A dependency-free mtime poll (skips target/ and node_modules/).
  9. Custom capability profilesrusm.toml [capabilities.<name>] profiles, each inheriting a built-in base with specific overrides. A component selects one by name.

Design highlights

  • Three languages, one wire. The JSON {op, args, from, ref}{ref, ok|err} wire is the contract. TS exports function add(a, b), Rust calls calc.add(2, 3), Go calls rusm.Call[int](calc, "add", 2, 3). No special interop layer needed — the wire IS the interop.
  • rquickjs over JCO. JCO transpiles Wasm to JavaScript to run in Node/Deno/Bun — the opposite direction. It gives no sandbox, no capability gating, and a full V8 instance per deployment. rquickjs runs JS inside the Wasm sandbox, with the full RUSM isolation model, at ~920 KB shared across all components.
  • Wizer snapshot = near-zero per-spawn engine cost. The QuickJS engine and full bridge are booted once at build time. Runtime spawn cost is CoW page mapping + evaluating the user's bundle. No engine initialization on the critical path.
  • import type erasure. A TS caller does import type { Calc } from "../calc" — erased at build time, so nothing from calc is bundled into the caller. Components communicate over messages, not imports. The type is just a type.

What this unlocks

Writing a RUSM component in any of the three languages is now as simple as exporting functions. The typed client, streaming, and callbacks make cross-component composition feel local. rusm dev closes the inner loop to seconds.

The todo-board example shows the complete picture: a store service, a reporter one-shot, and a streaming feed — in TypeScript, Rust, and Go — all interoperating over the same wire.

Try it

sh
# TypeScript todo-board — service + one-shot + streaming + callback:
cd examples/todo-board/typescript
rusm build && rusm serve

# Rust todo-board:
cd examples/todo-board/rust
rusm build && rusm serve

# Go todo-board:
cd examples/todo-board/go
rusm build && rusm serve
ts
export function add(a: number, b: number): number { return a + b; }
export function* countTo(n: number) { for (let i = 1; i <= n; i++) yield i; }
export type Calc = typeof import(".");
// caller: const calc = spawn<Calc>("calc"); await calc.add(2, 3)
rust
#[rusm_rs::service]
pub mod calc {
    pub fn add(a: i64, b: i64) -> i64 { a + b }
    pub fn count_to(n: i64) -> impl Iterator<Item = i64> { 1..=n }
}
// caller: let calc = calc::Client::spawn("calc")?; calc.add(2, 3)?
go
svc := rusm.NewService()
svc.Handle("add", rusm.Fn2(func(a, b int) (int, error) { return a + b, nil }))
svc.Serve()
// caller: sum, _ := rusm.Call[int](calc, "add", 2, 3)

Status

Phase complete. All three SDKs (TS, Rust, Go) ship and interoperate. Component-storm holds ~440k spawns/sec — no regression. rusm dev watch+reload works across all three languages.


Next: Phase 9 — distributed clusters: same send, same registry, now spanning machines over QUIC + mTLS at ~550k cross-node msgs/sec.

MIT licensed