Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Languages

An actor is a WebAssembly component implementing the wasmkeep:actor world: an ordinary wasi:http request handler, plus on-init, on-alarm and three WebSocket hooks, with access to its SQLite database, alarms, actor calls and WebSockets. Any language whose toolchain can produce a WASI 0.2 component can write actors. WasmKeep runs them directly on Wasmtime, with no JavaScript or other runtime in between unless the language brings its own.

Which language to use

WasmKeep runs WASI 0.2 components on a server. A language works if its toolchain can produce a component that implements the wasmkeep:actor world. Most “WebAssembly support” you read about targets the browser instead (wasm-bindgen, Emscripten, Blazor, Pyodide, Kotlin/Wasm for JS): that is a different target, and does not make a language usable here.

TierMeaning
RecommendedSDK, tutorial, examples passing the shared tests, and benchmark rows close to the best measured; a small component with no embedded runtime.
SupportedSDK; its counter and chat room pass the shared tests in CI with pinned tools; trade-offs stated with numbers from the bench files.
ExperimentalA counter passes the shared tests, but something Supported requires is missing: an SDK, a chat room, numbers from the bench files, or stable upstream tooling. No compatibility promise.
Not yetCannot produce a working actor today; the entry says what blocks it.

Today

LanguageTierHow it becomes a componentWhy this tier
RustRecommendedcargo build --target wasm32-wasip2, wasmkeep-sdkSDK, examples, tutorial; 81 KB; 104 µs of CPU per request, 250 KB per resident actor (measured); no adapter (WASI 0.2 directly)
ZigSupportedZig SDK over the C SDK; the WASI 0.1 adapterCounter, chat room and jobs app (KV and queue clients) pass; 30 KB, the smallest. Performs like Rust: 107 µs of CPU per request, 242 KB per actor (with --stack 1048576; Zig’s 16 MiB default stack takes megabytes more) (measured). Recommended once it has a guide
C / C++SupportedC SDK over wit-bindgen c; clang + wasi-libc, the WASI 0.1 adapterCounter, chat room and jobs app (KV and queue clients) pass; 170 KB. Performs like Rust: 110 µs of CPU per request, 244 KB per actor (measured). Recommended once it has a guide
TypeScript / JavaScriptSupported (embedded runtime)@wasmkeep/sdk: esbuild, then componentize-qjs (QuickJS embedded, pre-initialized); or ComponentizeJS (StarlingMonkey) with --engine starlingmonkey (below)Counter, chat room and jobs app (KV and queue clients) pass on both engines. QuickJS, the default: 1.7 MB; 230 µs of CPU per request (2.2× Rust) and 741 KB per actor (3×) (measured), few Web APIs. StarlingMonkey: Web APIs and 1.5× faster plain JavaScript, for 4.6× the memory per actor (measured)
PythonSupported (embedded runtime)Python SDK, componentize-py (CPython embedded)Counter, chat room and jobs app (KV and queue clients) pass. 18.7 MB, the largest; 259 µs of CPU per request (2.5× Rust) and 7.8 MB per actor (31×) (measured)
Go (TinyGo)SupportedGo SDK; TinyGo (0.39 in CI, 0.43 tried by hand) -target=wasip2, which writes a component directlyCounter, chat room and jobs app (KV, queue and workflow clients) pass; 162 KB. 248 µs of CPU per request (2.4× Rust) with a p99 up to 40× its median (the garbage collector), 390 KB per actor (measured, on the counter before the SDK: the same host calls without the wrapper); see Go
Go (standard)Experimentalcomponentize-go 0.5.1 with Go 1.27, straight on its bindingsThe counter passes; 2.7 MB. No SDK yet (the Go SDK is for TinyGo’s bindings), no numbers from bench/run.sh (see standard Go)
DExperimentalD SDK over wit-bindgen d; LDC (1.43 in CI) with -betterC, clang + wasi-libc, the WASI 0.1 adapterCounter and chat room pass; 88 KB. No numbers from bench/run.sh yet, no outbound HTTP or key-value and queue clients (see D)
MoonBitExperimentalMoonBit SDK over wit-bindgen moonbit; moon build --target wasm (MoonBit 0.10.20 in CI), then wasm-tools component embed --encoding utf16 and component newCounter and chat room pass; 49 KB. The language is pre-1.0; no numbers from bench/run.sh yet, no outbound HTTP or key-value and queue clients (see MoonBit)
C# / .NETExperimental (spike)componentize-dotnet 0.8.0-preview with NativeAOT-LLVM, straight on its bindingsThe counter passes; 929 KB stripped. No SDK, chat room or numbers from bench/run.sh; every tool is preview (see C#)

Getting an SDK

Each SDK has the version of the server it is released with (versions); here for 0.3.0.

LanguageGet itBuild an actor
Rustcargo add wasmkeep-sdk (crates.io)cargo build --release --target wasm32-wasip2 (tutorial)
TypeScript / JavaScriptnpm install @wasmkeep/sdk (npm)npx wasmkeep-ts build src/index.ts -o actor.wasm (README)
Pythonpip install wasmkeep (PyPI)python -m wasmkeep build app -o actor.wasm (README)
Go (TinyGo)go get github.com/wasmkeep/wasmkeep/sdks/go@v0.3.0 (a module of this repository)tinygo build -target=wasip2, with the WIT the module ships (README)
C / C++wasmkeep-c-sdk-0.3.0.tar.gz, a file of the releaseThe build command in the archive’s README (C SDK)
Zigwasmkeep-zig-sdk-0.3.0.tar.gz, a file of the releaseThe build command in the archive’s README (Zig SDK)
D, MoonBit, C#, standard GoThe repository’s source at the release’s tagEach example’s build.sh

The release’s files are signed: check a download.

Next, in order

Each is experimental once a counter passes the shared tests; numbers from bench/run.sh come before it is supported.

  1. An SDK for standard Go, which now builds actors through componentize-go (below).

Bring your own through the C SDK, the way Zig does: Odin and Nim should work (C-sized components); Swift and Haskell are possible with more effort (no bindings generator, larger runtimes). Recipes, not SDKs.

Not yet

LanguageWhat blocks itWhat would unblock it
KotlinKotlin/Wasm’s WASI target is still WASI 0.1; component support is an early JetBrains prototype on a forked bindings generator; it compiles to garbage-collected WasmJetBrains shipping WASI 0.2 and component-model support (on their roadmap), and WasmKeep enabling Wasm GC
JavaNo maintained path to a component: TeaVM removed its WASI backend, wit-bindgen removed its Java generator, GraalVM’s Wasm output targets browsers and NodeA maintained compiler and bindings generator
Scala, OCamlCompile to garbage-collected Wasm; WasmKeep’s Wasmtime is built without Wasm GC, and the component model cannot pass GC data yetEnabling Wasm GC in WasmKeep (its own decision: an ADR, benchmarks, pool sizing)
SwiftStable WASI 0.1 SDK, but no WIT bindings generator for importsA Swift wit-bindgen backend
AssemblyScriptIts maintainers reject the component model—
RubyNo bindings generator; very large runtimeA componentize-rb
PHPThe interpreter runs on WASI 0.1 as a CGI-style module (one script per request), not as a component; no bindings generator, so PHP cannot call storage or export the actor’s hooksA componentize-php (the interpreter as a library, wit-dylib glue, pre-initialization), the route componentize-py takes for Python
Grain, CrystalNo component supportUpstream work
Lua (via wit-dylib)Cannot export interface functions yet (our world needs incoming-handler), and needs Wasm exceptionsUpstream wit-dylib progress, Wasm GC/exceptions in WasmKeep
JavaScript via JavyProduces core modules, not componentsUse TypeScript on QuickJS instead

Component sizes

The same counter, as built by each example’s build.sh (sizes on disk; see benchmarks for speed and memory):

LanguageComponent
Zig30 KB
MoonBit49 KB
Rust81 KB
D88 KB
Go (TinyGo)162 KB
C170 KB
C#929 KB (NativeAOT, debug sections stripped)
TypeScript1.7 MB (QuickJS engine, the default)
Go (standard)2.7 MB (componentize-go; Go’s runtime)
TypeScript on StarlingMonkey13.4 MB (StarlingMonkey JS engine)
Python18.7 MB (CPython)

TypeScript on QuickJS

QuickJS is the default engine (ADR 0018): wasmkeep-ts build (npm run build in the TypeScript examples) uses componentize-qjs 0.5.0, which the pinned jco 1.41.0 already ships: the QuickJS-ng engine, pre-initialized with your bundle, built without component-model async (WasmKeep’s hooks are synchronous). wasmkeep-ts build --engine starlingmonkey (npm run build:starlingmonkey) builds the same source with ComponentizeJS and StarlingMonkey instead.

The counter, the chat room and the jobs app pass the same tests on both engines (crates/wasmkeep-server/tests/languages.rs, tests/chat.rs, tests/jobs_langs.rs). Measured on one machine (benchmarks), QuickJS against StarlingMonkey: 1.7 MB instead of 13.4 MB, 741 KB of memory per resident actor instead of 3.4 MB, and 230 µs of CPU per request instead of 368 µs on the counter; but 5.8 ms instead of 3.8 ms on plain JavaScript work.

Choose StarlingMonkey when the code (or an npm package it uses) needs the Web APIs below, or spends milliseconds computing in JavaScript per event.

What does not work, or works differently, on QuickJS:

  • Fewer Web APIs. QuickJS has no URL, URLSearchParams, crypto (getRandomValues, randomUUID), fetch, Headers, Request, Response, streams, Blob, AbortController, setTimeout or structuredClone; StarlingMonkey has all of these. Neither has Intl. Code or npm packages that use them fail at build time (during pre-initialization) or when called, with an error that names --engine starlingmonkey. They are not stubbed, so libraries that test typeof fetch still pick their fallback. QuickJS does have atob, btoa, queueMicrotask, performance and WeakRef. For outbound HTTP on either engine, the SDK has http.fetch: synchronous, as handlers are (StarlingMonkey’s own fetch returns a promise a handler cannot await).
  • console comes from the SDK, writing one line per call to stdout (log, info, debug) or stderr (warn, error), as StarlingMonkey’s does; objects are shown as JSON. WasmKeep logs both (what actors print).
  • TextEncoder and TextDecoder come from the SDK, UTF-8 only and without streaming decode. The SDK installs them when the engine lacks them, so the same code runs on both engines.
  • A WIT none is null, not undefined. The SDK’s env.get and alarms.get return undefined on both engines; code that imports wasmkeep:* or wasi:* interfaces directly sees null.
  • Slower JavaScript. Neither engine has a JIT, and QuickJS’s interpreter is slower: 5.8 ms against 3.8 ms for the same plain JavaScript work (measured). The counter spends little time in JavaScript, so there QuickJS wins on instantiation and the host.
  • Not covered by the shared tests: execDurable from TypeScript on either engine (actors.call is, through the jobs app’s key-value and queue clients). Alarms were checked by hand on QuickJS.

Go

TinyGo’s wasip2 target writes a component directly, against a WIT world it is given. The Go SDK is a wasmkeep package over the bindings wit-bindgen-go generates (committed in the module, so an app needs only TinyGo): an Actor of optional hooks, Exec, Query and Rows, alarms, Call, WebSockets, Env, Self and a synchronous Fetch, plus the key-value, queue and workflow clients. TinyGo’s runtime imports wasi:filesystem whether or not the program uses it, so the module’s wit/ adds those imports to the actor world (actor-tinygo); the host provides them inert (no directories).

An app adds the module with go get github.com/wasmkeep/wasmkeep/sdks/go@v0.3.0: the module carries the server’s version number. The wasmkeep package starts at v0.2.0 (v0.1.0 has only the clients); see the SDK’s Install.

The counter, chat room and jobs app pass the shared tests, and fetch_langs checks Fetch against the Rust SDK’s http::fetch. Trade-offs:

  • A garbage collector. TinyGo’s runs inside the request: the counter’s p99 is 10× (reads) to 40× (writes) its median, against under 1.5× for Rust (measured). Allocating less per event shortens it.
  • TinyGo, not Go. TinyGo compiles most of the standard library, not all of it (its documentation lists which packages build); the SDK keeps to packages it handles well (no fmt or reflection).
  • Size. 162 KB for the counter, about twice Rust’s; strconv’s number parsing alone adds about 50 KB, so the SDK reads integers from SQLite without it.
  • Not covered by the shared tests: SetAlarm and the Alarm hook, ExecDurable and DeleteSelf from Go (Call is, through the jobs app’s clients).

Standard Go, through componentize-go

Standard Go (not TinyGo) builds actors through componentize-go 0.5.1 with Go 1.27: the counter passes the same counter contract as Rust, and CI builds it. What blocked our own attempt before (Go’s runtime assumes it owns all linear memory past its data, while the WASI 0.1 adapter needs memory before Go starts) does not show: the component instantiates, survives hibernation and restarts.

What works:

  • The whole world. componentize-go bindings generates Go packages for every interface of crates/wasmkeep-wit/wit, and the app implements two packages it names: export_wit_world (the five hooks, returning witTypes.Result[witTypes.Unit, string]) and export_wasi_http_incoming_handler (Handle). The component imports exactly the world’s 0.2.12 interfaces.
  • Go’s own compiler and runtime, not TinyGo’s subset of them: what builds with go build for wasip1 builds here.

What is missing:

  • An SDK. componentize-go’s bindings (go.bytecodealliance.org/pkg) are not the wit-bindgen-go ones the Go SDK wraps for TinyGo, so the SDK does not build on standard Go yet. Its kv, queue and workflow clients import no bindings and would carry over.
  • Size. 2.7 MB, most of it Go’s runtime (stripping custom sections saves under 3%), against 162 KB on TinyGo; no numbers from bench/run.sh for CPU or memory per actor yet.
  • Rough edges. The module must be named wit_component (the bindings import it by that path), bindings rewrites go.mod from its own template on every run (the example’s build.sh keeps the committed one), the build needs -buildvcs=false inside a git checkout, and the tool downloads its own binary from its GitHub release into the user’s cache directory on first use.

D

LDC compiles D with -betterC (no garbage collector, no D runtime, no exceptions) to objects for wasm32-unknown-wasi; clang links them against wasi-libc as a reactor, and the WASI 0.1 adapter makes the component, as for C. wit-bindgen d generates the bindings, and the D SDK wraps them: a struct of static hooks exported with mixin Actor!S, exec, query and Rows with parameters as plain D arguments, alarms, call, WebSockets, env and self. Without a garbage collector, everything the SDK returns lives until the event ends and is freed then.

The counter and chat room pass the shared tests. D stays experimental until bench/run.sh measures it; the SDK has no outbound HTTP, key-value, queue or workflow clients yet. -betterC also means no D standard library parts that need the runtime (most of Phobos): text is built with reply’s printf formats or arena memory.

MoonBit

wit-bindgen moonbit generates the bindings as a MoonBit module whose world package declares the hooks (declare pub fn on_init, …); the MoonBit SDK’s build.sh adds the SDK package, the app package and a small glue file that implements those declarations by calling the app’s actor (an @wasmkeep.Actor of optional hooks). moon build --target wasm makes a core module with no WASI imports of its own, and wasm-tools makes the component, with UTF-16 strings (MoonBit’s). The SDK wraps storage (exec, query, Rows), alarms, call, WebSockets, env and self_address; failures are MoonBit errors (Failure), raised from the SDK and caught at the hooks.

The counter and chat room pass the shared tests. MoonBit stays experimental: the language is pre-1.0 (its string layout changed in 2026, so the compiler, core library and wit-bindgen are pinned together), bench/run.sh does not measure it yet, and the SDK has no outbound HTTP, key-value, queue or workflow clients. MoonBit’s servers publish versions by their compiler version (0.10.20+65efd5b93, as moon version --all prints it), not by moon’s dated version; CI pins that one, checked by SHA-256.

C# (spike)

C# can produce a WasmKeep actor today. The counter is built with componentize-dotnet 0.8.0-preview00011 and NativeAOT-LLVM 10.0.0-rc.1 on the .NET 10 SDK, and passes the same counter contract as Rust; CI builds it.

What works:

  • The whole world. componentize-dotnet runs wit-bindgen c-sharp on crates/wasmkeep-wit/wit at every build; the app implements two generated interfaces (IActorWorldExports for the five hooks, IIncomingHandlerExports for requests) and calls the imports as static methods (IStorageImports.Exec, …).
  • One build command. dotnet build restores the compiler and the tools it needs and writes a WASI 0.2 component directly; no adapter.
  • Errors as exceptions. A WIT error result is thrown as a WitException; a hook fails its event by throwing WitException<string>.

What is missing:

  • Everything is preview. The compiler comes from Microsoft’s dotnet-experimental feed (a release candidate), the packages are previews, and the first restore is about 800 MB.
  • No macOS. At the version componentize-dotnet uses, the NativeAOT-LLVM compiler is published for Linux (x64 and arm64) and Windows x64; the macOS packages on the feed are years older, and Counter.csproj names the Linux x64 one.
  • An older filesystem import. The component imports wasi:filesystem@0.2.6 where the world has 0.2.12, so wasm-tools component targets rejects it; WasmKeep links the imports semver-compatibly and runs it.
  • No SDK. The code names generated types in full (ActorWorld.wit.Imports.wasi.http.v0_2_12.ITypesImports.OutgoingResponse) and does its own HTTP plumbing; an SDK on the bindings, the chat room and numbers from bench/run.sh come before a tier above experimental.
  • Size. 2.5 MB as built, 929 KB with its debug sections stripped (wasm-tools strip --all, which build.sh runs): NativeAOT compiles the runtime and the garbage collector in.

Versions

The SDKs carry the server’s version: one number for the wasmkeep binary, the Rust crate and every other SDK manifest, changed together. Use the SDK with the same version as the server you run: other pairings are not tested.

SDKServerWIT package
0.3.00.3.0wasmkeep:actor@0.2.0
0.2.00.2.0wasmkeep:actor@0.2.0
0.1.00.1.0wasmkeep:actor@0.2.0

0.2.0 is the first version on the registries (crates.io, npm, PyPI); 0.1.0 was used from a clone of the repository. That the SDK and the WIT package both read 0.2.0 in the 0.2.0 row is a coincidence.

The WIT package version is another number: it is the version of the interface between a component and the host (actor.wit), and changes only when that interface does.

scripts/check-versions.py (run by ./scripts/check.sh and CI) fails when a manifest it lists has another version than the workspace’s in Cargo.toml; an SDK that gains a manifest with a version is added to that list. The C SDK’s is WASMKEEP_SDK_VERSION in wasmkeep.h, the Zig SDK’s version in wasmkeep.zig; a release carries both SDKs as archives of that version (wasmkeep-c-sdk-<version>.tar.gz, wasmkeep-zig-sdk-<version>.tar.gz).

What the tiers require

  • Experimental: a counter passes the same black-box test as the Rust one (create, increment, persist across hibernation and restart).
  • Supported: an SDK; the counter and the chat room pass the shared tests (--test languages, --test chat); it builds in CI from a clean checkout with pinned tool versions; this page states its trade-offs with numbers from bench/results.
  • Recommended: all of that, a guide (how to start an actor and call the storage, alarm, call and WebSocket APIs; today only Rust has one), and benchmark rows close to the best measured.

Languages that embed an interpreter or a garbage-collected runtime (JavaScript, Python, Go, C#) produce larger components and wake more slowly than Rust, C or Zig. The supported ones’ numbers are published from bench/run.sh like the others, so the trade-off is visible before you choose.

Bring your own

If your language can produce a component that imports and exports what the world declares, it works: there is nothing WasmKeep-specific in the binary format. Generate bindings from crates/wasmkeep-wit/wit with your toolchain’s wit-bindgen (or equivalent), and check the result with wasm-tools component wit your.wasm.