Skip to content

Concept — guests: Rust & TypeScript

A RUSM process body can be written in Rust (rusm-rs) or TypeScript (rusm-ts). Both compile/bundle to a sandboxed Wasm process with the same actor API and the same JSON wire — so a Rust client and a TypeScript service interoperate transparently.

Rust guests (rusm-rs)

Ergonomic Pid / send / receive / spawn / registry / Stream, plus a #[rusm_rs::service] macro that generates the receive → dispatch → reply loop and a typed Client with call / cast / streaming / callbacks. rusm build compiles each components/<name>/ with cargo build --target wasm32-wasip2 — one toolchain, no cargo-component, no jco.

Serving — #[rusm_rs::handlers]

A Rust serving component is a module of pub fns under #[rusm_rs::handlers]no main, no router, no wire plumbing. The macro generates the component shell and the action dispatch; the route is named in that listener's [serve.routes] subtable as "component#action". A 2-arg action is buffered; a 3-arg action streams SSE (each request is its own process, so it may block for the whole connection):

rust
use rusm_rs::http::{Params, Request, Response, Sse};

#[rusm_rs::handlers]
pub mod api {
    use super::*;

    // GET /users/:id  ->  "api#show"
    pub fn show(_req: Request, p: Params) -> Response {
        Response::text(format!("user {}\n", p.get("id").unwrap_or("?")))
    }

    // GET /events/:room  ->  "api#events"  (3 args → SSE)
    pub fn events(_req: Request, p: Params, sse: Sse) {
        let room = p.get("room").unwrap_or("lobby").to_string();
        for n in 0.. {
            if !sse.data(format!("{room} tick {n}").as_bytes()) { break; } // disconnected
        }
    }
}

WebSockets are per connection: implement ws::Handler and ws::serve it. The host runs one isolated process per connection, hands each inbound frame to message, and you reply through the Connection (the per-connection process simply exits on disconnect — there is no close callback):

rust
use rusm_rs::ws::{self, Connection, Handler};

#[derive(Default)]
struct Echo;

impl Handler for Echo {
    fn open(&mut self, conn: &Connection) { conn.send(b"welcome\n"); }
    fn message(&mut self, conn: &Connection, data: Vec<u8>) { conn.send(&data); } // echo
}

#[rusm_rs::main]
fn main() { ws::serve(Echo::default()); }

State that must outlive a request never lives in the serving instance — it goes in a long-lived [components.<name>] service (resident = true) reached over the actor API (whereis / call / send) or in durable kv.

TypeScript guests (rusm-ts)

Import the rusm-ts package: a service is just exported functions, a worker is export default. The concealed typed client makes await svc.method(...) read like a local call — with for await streaming and callback arguments — while spawn / send / receive stay hidden. rusm build bundles each component with Bun into a small .js.

Serving — web standards

TS serving uses web standards (the #[handlers] macro is Rust-only) and needs no [serve.routes] table — the component is the handler. HTTP/SSE is export default a fetch-shaped function returning a Response; SSE returns a streaming ReadableStream body. WS is export default websocket({ open, message }) from the rusm-ts package — one worker process per connection:

ts
// HTTP/SSE — a per-request wasi:http component
export default function handle(request: Request): Response {
  const who = new URL(request.url).searchParams.get("who") ?? "world";
  return new Response(`hello, ${who}\n`, { headers: { "content-type": "text/plain" } });
}
ts
// WS — one worker per connection
import { websocket } from "rusm-ts";

export default websocket({
  open(s)        { s.send("welcome\n"); },
  message(s, d)  { s.send(d); },          // echo
});

Beyond messaging — timers, storage, pub/sub, crypto

Both guests get more than send/receive, all over the same capability-gated ABI:

  • Timed receivereceive_timeout(ms) (RS) / Process.receive(ms) (TS): Erlang's receive … after — the next message, or null/None on the deadline. The basis for heartbeats and any time-bound wait, with no busy loop.
  • Durable storagerusm_rs::kv (RS) / the kv global (TS): bucketed get/set/delete/exists/list over the node's embedded store (rusm-kv/redb), gated by the storage capability. Survives a restart, no external daemon. (TS bundles can also import npm — @noble/*, etc.)
  • Pub/sub fan-outrusm_rs::pubsub::Topics: keyed subscriber tracking + fan-out + monitor-based pruning of dead subscribers (the broker mechanics as a primitive, so app code carries none of it). Pairs naturally with SSE serving: a per-request SSE action subscribes to a topic and live-tails it via Sse::run, and the monitor prunes the subscriber when the request process exits on disconnect.
  • Outbound fetch + Web Crypto (TS): a capability-gated streaming fetch, and a native crypto.subtle (RustCrypto: SHA digest, HMAC sign/verify, AES-GCM) — so the Anthropic SDK and JWT/signing libraries work inside the sandbox.

Logging from a component

There's nothing new to learn — use each language's standard output, and the host shows it. A component logs only when its profile grants the allow-stdio capability ([capabilities.<name>] allow-stdio = true, included in trusted); a sandboxed guest's output is simply discarded.

rust
// Plain std macros → the node's stdout / stderr (when `allow-stdio` is granted).
println!("handled {} in {}ms", id, elapsed);
eprintln!("warning: retrying ({attempt})");
// For levelled/tagged app logs, write a thin helper over eprintln! (no framework
// needed) — or wire the `log`/`tracing` crates to a stderr backend if you prefer.
ts
// The web-standard console → the node's stderr (warn/error are prefixed).
console.log("handled", id, "in", elapsed, "ms");
console.warn("retrying", attempt);   // → [warn] retrying 2
console.error("gave up");            // → [error] gave up
// Pids (bigint) and objects are stringified/JSON'd for you.

These are your application logs. They're distinct from the platform lifecycle log ([log] level = …rusm spawn/exit component#pid …, configuration), which the runtime emits and tags rusm so the two are easy to tell apart on stderr.

The shared runner — tiny TS components (vs jco)

A TypeScript component is just its bundle running on one shared ~920 KB rquickjs runner: the JS engine is compiled once and shared by every TS process. Contrast jco / ComponentizeJS, which bakes a multi-megabyte JS engine (StarlingMonkey) into every component. Ship 50 TS components and RUSM ships the engine once, not fifty times — far smaller and saner.

It's also the only option that keeps a JS guest inside the Wasm sandbox: rquickjs compiles to wasm32-wasip2, so a TS guest gets the same memory isolation, capabilities and preemption as a Rust one. (A native engine like V8/deno_core can't run inside a component.)

Bytecode precompile

rusm build precompiles each bundle to version-locked QuickJS bytecode (wasm/<name>.qjsbc); the runner loads it straight into the VM, skipping the parser on cold start. Full JS + npm is kept — the engine is shared, not embedded per component.

Phase 8 (the rusm-rs / rusm-ts SDKs + the rquickjs runner); bytecode precompile is a later optimization on the same shared runner.

MIT licensed