Skip to content

Building a component

One exported function — greet(name: string) -> string — written six times, in Rust, TinyGo, standard Go, C, JavaScript and Python, against a single .wit file and built six different ways. That is examples/componentize. The Go code that calls the result is identical for all six and cannot tell which toolchain produced the binary, so this page is the part that happens before wazy is involved at all: turning a library you already have into a .wasm a host can load.

examples/componentize/rust/greet.wit
package wazy:examples@0.1.0;
world greeter {
export greet: func(name: string) -> string;
}

That file is byte-identical in all six directories. It is the whole contract.

All six components are committed, so you can see the contract come back out of a binary before installing anything. The C one is 13 KB. From examples/componentize/c:

Terminal window
wasm-tools component wit greet.wasm
world root {
export greet: func(name: string) -> string;
}

Handing that to wazy is two lines, whichever language built it:

inst, err := component.Instantiate(ctx, r, greetWasm, wasip2.With(wasip2.Config{})...)
out, err := inst.Call(ctx, "greet", "wazy") // "Hello, wazy! (from TinyGo)"

greet is exported by the world rather than by an interface, so there is no interface prefix and no CallExport — the two-argument form Components, threads and async needed for component:adder/calc. That page is the host program in full; this one is the other half of it.

Language Toolchain greet.wasm Needs WASI 0.2
Rust cargo-component 0.21.1 46 KB yes
TinyGo TinyGo 0.42.0 + wasm-tools 470 KB yes
Go go1.26 + wasm-tools + preview1 adapter 1.8 MB yes
C wasi-sdk 34.0 + wit-bindgen 0.47.0 13 KB no
JavaScript jco 1.32.1 12 MB yes
Python componentize-py 0.25.0 18 MB yes, plus extended-const

The built .wasm is in the repo for every one of them, so the commands in each tab are for changing a guest, not for getting one.

  • Directoryexamples/componentize
    • Directoryc
      • greet.wit
      • greet.c
    • Directorygo
      • greet.wit
      • main.go the Canonical ABI, written out by hand
    • Directoryjavascript
      • greet.wit
      • greet.js
    • Directorypython
      • greet.wit
      • app.py
    • Directoryrust
      • Cargo.toml
      • greet.wit
      • Directorysrc
        • lib.rs
    • Directorytinygo
      • build.wit a build-only world, not the contract
      • Directorygen/ generated bindings, committed
        • …
      • greet.wit
      • main.go

The shortest path of the six, and the one with the least between your code and the component.

Terminal window
rustup target add wasm32-wasip2
cargo install cargo-component --locked
Terminal window
cargo component build --release --target wasm32-wasip2
cp target/wasm32-wasip2/release/greet.wasm greet.wasm
examples/componentize/rust/src/lib.rs
#[allow(warnings)]
mod bindings;
use bindings::Guest;
struct Component;
impl Guest for Component {
fn greet(name: String) -> String {
format!("Hello, {name}! (from Rust)")
}
}
bindings::export!(Component with_types_in bindings);

cargo component regenerates src/bindings.rs from greet.wit on every build, so the generated bindings are not committed. It finds the WIT through the manifest, not a command-line flag:

examples/componentize/rust/Cargo.toml
[package]
name = "greet"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
wit-bindgen-rt = { version = "0.44.0", features = ["bitflags"] }
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "s"
strip = true
lto = true
[package.metadata.component]
package = "wazy:examples"
[package.metadata.component.target]
path = "greet.wit"
world = "greeter"

greet itself touches no WASI, but Rust’s std links its panic and stdio machinery unconditionally, so the component still declares wasi:cli, wasi:io, wasi:clocks and wasi:filesystem imports. Instantiate it with wasip2.With(wasip2.Config{}).

For five of the six, nothing beyond WASI 0.2 with a zero-value config:

inst, err := component.Instantiate(ctx, r, greetWasm, wasip2.With(wasip2.Config{})...)

wasip2.Config{} grants no stdio, no filesystem and no sockets. That is enough for a language runtime to finish starting up, and nothing more — the guest cannot read a file you did not mount. The C component needs even that not at all, because it imports nothing.

The CPython guest additionally needs a Core WebAssembly feature the default set leaves off:

r := wazy.NewRuntimeWithConfig(ctx, wazy.NewRuntimeConfig().
WithCoreFeatures(api.CoreFeaturesV2|api.CoreFeatureExtendedConst))
inst, err := component.Instantiate(ctx, r, greetWasm, wasip2.With(wasip2.Config{})...)

componentize-py’s CPython build initializes a global with i32.add, which the default api.CoreFeaturesV2 rejects. api.CoreFeaturesV3 works too.

Nearly every component imports WASI, whatever the code does

Section titled “Nearly every component imports WASI, whatever the code does”

C is the exception. Rust’s std links panic and stdio machinery unconditionally. TinyGo’s syscall package initialises itself on every wasip2 binary, reading the environment, the initial working directory and the preopened directories — about 33 WASI 0.2 functions no matter what the guest does. A standard-Go binary carries the Go runtime, which reads the clock and the environment on the way up. The JavaScript engine reaches for wasi:clocks, wasi:random and stdio while initialising, and traps on the first unresolved import without them: wasi:clocks/monotonic-clock.now not implemented (trap stub). CPython calls wasi:cli/environment during interpreter start-up, before it ever reaches greet.

Assume you need wasip2.With(wasip2.Config{}) and check with wasm-tools component wit greet.wasm if you want to know exactly what a binary asks for.

Size is the language runtime, not your program

Section titled “Size is the language runtime, not your program”

Thirteen kilobytes to eighteen megabytes, for one function returning a string. Nothing in the guest source explains the range: C and Rust ship your code, TinyGo and Go ship a small runtime with it, and jco and componentize-py ship an entire language implementation. That cost is paid once — a real JavaScript plugin is the same 12 MB plus its own source — and it does not reach the host, which calls all six the same way.

TinyGo’s directory has a second .wit file, and it is not a duplicate:

examples/componentize/tinygo/build.wit
// Build-only world. NOT part of the contract: greet.wit is.
//
// TinyGo's runtime links a fixed slice of WASI 0.2 into every wasip2 binary
// (package syscall's init reads the environment and the preopened
// directories), so the world handed to `wasm-tools component embed` has to
// declare those imports or `component new` rejects the module. `greeter` here
// is the world from greet.wit -- same package, so it is included by name.
//
// The component this produces still exports exactly `greet: func(name:
// string) -> string`; run `wasm-tools component wit greet.wasm` to see it.
world greeter-tinygo {
include greeter;
include wasi:cli/imports@0.2.0;
}

wasm-tools component new refuses a core module whose imports the world does not declare, and no build flag (-gc=leaking -scheduler=none -panic=trap -no-debug) removes TinyGo’s. So the world you build against gains wasi:cli/imports, while greet.wit — the file a consumer reads — stays byte-identical to the other five. The component still exports exactly greet.

componentize-py needs extended-const, above — the only host-side setting any of the six asks for beyond WASI 0.2. The other place the choice of toolchain shows up is jco --backend quickjs, the 1.5 MB option, which is not what the committed greet.wasm was built with. examples/componentize/javascript/README.md records that output failing to instantiate on an instance-sort instantiate-arg: the QuickJS backend emits a wac-composed component whose component-instance index space is mostly imported and aliased instances rather than nested instantiations. wazy resolves both of those now — it is the same shape as every pair in Composing components — so that note predates the current composition path and has not been re-checked against it. The default StarlingMonkey backend, which the build command above uses, is unaffected either way.

Once you can build one component, the interesting move is building two and wiring them together without a host in between.