Skip to content

Write a TypeScript component

You write TypeScript. RUSM bundles it with Bun and runs it as a sandboxed, supervised process — isolated memory, capability-gated I/O, crash-recovered by the supervisor. No jco, no boilerplate, no raw WASM tooling. Write logic; RUSM handles the rest.

Scaffold & run in 30 seconds

rusm new creates a complete new RUSM project — rusm.toml, a components/ folder, and a starter component ready to build and serve:

sh
rusm new myapp       # new project with a TypeScript HTTP component
cd myapp
rusm build           # bun build → wasm/api.js
rusm serve           # live on http://127.0.0.1:8080

Want WebSocket or SSE instead?

sh
rusm new myapp --protocol ws    # new project with a WebSocket component
rusm new myapp --protocol sse   # new project with an SSE component

Adding a component to an existing project

Use rusm generate component <name> [--lang ts|rust|go] [--protocol http|ws|sse] — it adds a new components/<name>/ and the matching rusm.toml entry without touching anything else. rusm generate bridge <name> scaffolds a new host bridge the same way.

A component is a folder under components/ with a single index.ts:

my-app/
├── rusm.toml
├── components/
│   └── api/
│       └── index.ts
└── wasm/                   # rusm build writes api.js here

Two shapes

Service — export functions

Export named functions. RUSM generates the receive → dispatch → reply loop around them automatically. The caller gets a typed proxy — ordinary await calls that are actually cross-process messages:

ts
// components/calc/index.ts
export function add(a: number, b: number): number { return a + b; }
export async function greet({ name }: { name: string }) { return `hi ${name}`; }

// Publish the contract — derived from the exports above, so it never drifts.
export type Calc = typeof import(".");

Worker — export default

Export a default async function. RUSM runs it once; it does its job and exits. Use the typed client to reach a service — spawn<T> returns a proxy where every method is a real cross-process call hidden behind await:

ts
// components/commander/index.ts
import { spawn } from "rusm-ts";
import type { Calc } from "../calc";   // type-only import — never bundled in

export default async function () {
  const calc = spawn<Calc>("calc");

  console.log("2 + 3 =", await calc.add(2, 3));             // typed call
  for await (const n of calc.countTo(3)) console.log(n);    // streaming
  await calc.work((pct) => console.log(`progress ${pct}`)); // callback
}

calc stays a separate component — the import type is erased at build time, so nothing from calc is bundled into commander. They communicate over messages, not imports.

Declare in rusm.toml

Every component needs an entry. The spawner needs the allow-spawn capability:

toml
[components.calc]
capability = "sandboxed"

[components.commander]
capability = "trusted"   # inherits allow-spawn

Add the SDK

json
{ "dependencies": { "rusm-ts": "^0.6.0" } }

rusm build runs bun install automatically before bundling.

Build & run

sh
rusm build   # bun build → wasm/*.js for every component
rusm run     # spawn them per rusm.toml
rusm dev     # build + run, then watch ./components and hot-reload on every save

The Process API

The full actor toolkit — all typed by rusm-ts:

Process.self()this process's pid
Process.send(pid, msg)send a message
Process.receive() / receiveText()wait for a message (suspends the fiber — cheap)
Process.spawn(name)spawn a component by rusm.toml name
spawn<T>(name) / connect<T>(name)typed cross-process call (spawn a service / reach a running one)
Process.register(name) / whereis(name)named registry
Process.registerTag(tag) / whereisTag(tag)process-group tags
Process.sendAfter(pid, ms, msg) / cancelTimer(h)timers
Process.monitor(pid)watch for a process exit (a __down message)
Process.kill(pid)terminate another process
Process.openStream(pid) / acceptStream()byte streams
Process.setLabel(label)a label for the observer

receive and receiveText are async — they suspend the fiber while waiting, freeing the scheduler for other work. No threads, no event loop fights.

fetch works — capability-gated. A network-client guest gets full HTTPS with streaming bodies and AbortSignal; a sandboxed guest's fetch rejects with a clear error. crypto.getRandomValues and randomUUID are available to every guest.

Add "lib": ["ES2022", "DOM"] to your tsconfig.json for the Web API types.

Logging is zero-config

console.log/info/warn/error/debug routes to the node's unified log stream — stamped with the time, component#pid, and a severity colour. No setup, no allow-stdio grant, no logger object. Gated by [log] level in rusm.toml.

How TypeScript runs in RUSM

RUSM ships a single ~920 KB js-runnerQuickJS compiled to wasm32-wasip2 via rquickjs. This shapes everything about how TS components perform:

Wizer pre-initialization. At build time, wizer boots the QuickJS engine and the full JS bridge — all Process.*, fetch, crypto, and kv primitives — and snapshots the result into the binary. Every spawned instance copy-on-write starts from that warm snapshot: the engine never boots from scratch at runtime; each spawn only evaluates your Bun-bundled .js. This gives roughly 8× better cold per-request throughput vs a non-pre-initialized runner.

One engine, every component. All your TypeScript components share the same js-runner binary. You ship the engine once. Each spawned instance starts from the wizer snapshot and the OS uses copy-on-write (CoW) to share it: every instance reads from the same physical memory pages until it writes to one, at which point only that page is copied for that instance. The 920 KB engine image is never duplicated in full — you pay only for the pages each instance actually diverges from the snapshot.

RUSM TS components vs ComponentizeJS

The closest comparison for running TypeScript as a sandboxed Wasm process is ComponentizeJS — the Bytecode Alliance tool that compiles JS/TS to a Wasm component by embedding StarlingMonkey (a SpiderMonkey variant). JCO then transpiles those components to run in Node.js or a browser.

Both approaches run JavaScript inside Wasm. The differences are in engine sharing, actor model, and operational model:

RUSM + rquickjsComponentizeJS + JCO
JS engineQuickJS (~920 KB), shared across all TS components on a nodeStarlingMonkey (8–20 MB+), embedded separately in each component
Your component artifact.js bundle of your code (2–50 KB).wasm with engine included (8–20 MB+ per component)
Wizer pre-init✓ engine + bridge snapshotted once at build time✓ per-component snapshot
Engine sharing✓ CoW-shared; one copy in memory regardless of instance count✗ each component carries its own full engine copy
Default-deny capabilities✓ per-process, host-enforced✗ WASI shims in Node.js host; no default-deny model
Memory cap per instanceStoreLimiter
Epoch preemption✓ a spinning guest can't starve others
Actor model✓ supervised, addressable, killable, mailbox

The engine-sharing gap is the headline. A real TypeScript component with real dependencies ships at 17–19 MB with ComponentizeJS — and that cost multiplies. The ~920 KB js-runner is QuickJS bytecode (engine + full bridge, pre-compiled by wizer). Each RUSM component adds only its .js bundle, which QuickJS compiles to bytecode in memory at spawn time. The engine cost is fixed; only your code scales with component count:

1 component10 components
RUSM~920 KB engine + ~10–50 KB code~920 KB engine + ~100–500 KB code
ComponentizeJS~17–19 MB~170–190 MB

Go deeper

MIT licensed