LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

The WASI Component Model: WebAssembly's Missing Piece

webassemblywasicomponent-modelwitwasmtimespinpolyglotedge-computing
Contents

WebAssembly modules are islands. Each one gets a flat block of linear memory — bytes with addresses. When you want to call a function in another module, you pass integers and floats. You want to send a string? Pass a pointer (an integer) and a length (another integer) and hope both sides agree on the encoding. Want to return a list of structs? Better have a memory-allocation protocol documented somewhere. In practice, this worked well enough for browser use cases — JS held everything together. But for server-side polyglot architectures, it was a fundamental blocker.

The WASI Component Model is the specification that solves this. It defines a standard for composing WebAssembly modules with rich, typed interfaces — strings, lists, records, variants, resources — across language boundaries without any serialization overhead or out-of-band protocol negotiation. It is also the foundation of WASI Preview 2, the current generation of the WebAssembly System Interface.

This post goes deep. We will cover the problem, the specification, the WIT interface definition language, the toolchain, wasmtime, Fermyon Spin, component composition, plugin systems, the polyglot story, and an honest assessment of what is ready for production today.


The Interface Problem

What WebAssembly Modules Can Actually Exchange

A WebAssembly module in the traditional (pre-Component-Model) sense — called a core module — communicates through a handful of primitive types:

  • i32, i64 — 32-bit and 64-bit integers
  • f32, f64 — 32-bit and 64-bit floats
  • v128 — 128-bit vector (SIMD)
  • funcref, externref — reference types (table entries)

That is the entire type system at the module boundary. There are no strings. No arrays. No structs. No booleans as a type. No null. Everything you want to pass between modules must be encoded as integers.

In practice, passing a string from module A to module B looks like this: module A allocates memory in its linear memory space, writes the UTF-8 encoded bytes of the string, then passes two integers to module B — a pointer (offset into linear memory) and a byte length. Module B then has to read from that same linear memory.

There are two immediate problems:

Problem 1: Shared linear memory. For module B to read the string that module A wrote, they need to share the same linear memory. In the WASM 1.0 model, each module has its own memory by default. There are two workarounds: import the memory from the host (both modules share a single memory provided by the embedder), or use the proposed shared-memory proposal. Both have serious implications — shared memory between untrusted components breaks isolation.

Problem 2: No standard ABI. Even if you solve the memory sharing problem, you still need both sides to agree on how to lay out complex types. Where does the string go in memory? Who allocates it? Who frees it? What about a list of strings? A struct containing a list of strings and an optional integer? Every language and framework invents its own conventions. C and Rust have different ABIs. Go uses different calling conventions entirely.

This was addressed in practice by frameworks like Emscripten (which compiles C++ to WASM and generates JavaScript glue code), wasm-bindgen (which does the same for Rust-to-JavaScript), and various host-specific SDKs. But these solutions are pairwise — they work for one specific source language calling one specific target environment. They do not generalize to arbitrary language-to-language communication.

The Shared-Nothing Model

The Component Model takes a philosophically different approach: shared nothing. Components do not share memory with each other. Period. When component A calls component B, B cannot read A’s memory. The call boundary enforces isolation.

This is unlike traditional native linking (where two .o files get linked into one process that shares memory), unlike container networking (where two containers communicate through the OS network stack), and unlike gRPC (where communication goes through a TCP connection and protobuf serialization, also through the OS).

The Component Model achieves shared nothing while still allowing efficient cross-component calls through the Canonical ABI — a specification for how complex types are lowered into the primitive integer types that cross the WASM boundary, how memory is allocated and freed for this purpose, and how strings and lists are passed by value (copied) across component boundaries without sharing memory.

Copying sounds expensive, but consider what you are replacing: if the alternative is a gRPC call with TCP, TLS, HTTP/2 framing, and protobuf encoding/decoding, copying a few hundred bytes of string data is dramatically cheaper. The Component Model is designed for in-process composition where components run in the same runtime instance.

The Versioning and Interface Documentation Problem

Beyond the technical ABI problem, there was a social problem: how do you document what a WASM module expects from its environment? WASI Preview 1 published its interface as a set of .witx files (an earlier format). But for application-level interfaces — your plugin interface, your service interface, your library interface — there was no standard IDL (interface definition language).

Different projects invented different solutions. Extism used a JSON-based approach. Various WASM plugin systems just documented the expected export names and memory conventions in prose. This made tooling nearly impossible: no code generation, no validation, no automatic binding generation.

The Component Model’s answer to this is WIT — WebAssembly Interface Types — a proper IDL that serves as the single source of truth for component interfaces.


What the Component Model Is

The WebAssembly Component Model is a specification developed by the Bytecode Alliance (the consortium that includes Mozilla, Microsoft, Fastly, Intel, Amazon, Cisco, and others) under the WebAssembly CG (Community Group). It consists of several interrelated specifications:

The Component Binary Format: An extension to the WASM binary format that allows a component to be a self-describing package that contains core modules plus metadata about what it imports and exports. A component file has a .wasm extension like a regular module, but its binary header and structure are different. Components can contain other components.

WIT (WebAssembly Interface Types): The IDL for describing component interfaces. WIT files describe interfaces (named sets of functions and types), worlds (the combination of a component’s imports and exports), and packages (collections of related interfaces). WIT is the lingua franca of the Component Model — the source of truth from which language bindings are generated.

The Canonical ABI: The specification for how WIT types map to the primitive types that cross WASM boundaries. The Canonical ABI defines exactly how a string is passed (a pointer and length, where the encoding is always UTF-8), how a list<T> is passed (a pointer and element count), how an option<T> is represented (a flag integer plus the value), how result<T, E> is represented, and so on. Every runtime that implements the Component Model must implement the same Canonical ABI.

WASI Preview 2: The set of standard system interfaces defined using WIT and the Component Model. Where WASI Preview 1 defined POSIX-like interfaces using the older WITX format, WASI Preview 2 defines interfaces like wasi:filesystem, wasi:http, wasi:sockets, and wasi:cli using WIT. WASI Preview 2 shipped as stable in early 2024.

Components vs. Core Modules

It is important to understand the distinction:

A core module is a standard WASM binary. It is what rustc --target wasm32-unknown-unknown or tinygo produce by default. It has imports and exports, but they are all in terms of the primitive WASM types. Core modules can be used directly by runtimes, and they can be embedded inside components.

A component is a higher-level construct. It wraps one or more core modules (or other components) and describes its interface in terms of WIT types. A component knows that its export process-request takes a record { method: string, path: string, headers: list<tuple<string, string>> } and returns a result<record { status: u32, body: list<u8> }, string>. A runtime that loads a component can verify this statically, generate host bindings for it, and compose it with other components.

You can think of the relationship like this: core modules are to components as C object files are to shared libraries with a stable ABI. The component is the deployable artifact with a documented, typed interface.

┌─────────────────────────────────────────────┐
│              Component (.wasm)              │
│                                             │
│  Imports:  wasi:http/incoming-handler       │
│            wasi:keyvalue/store              │
│                                             │
│  Exports:  myapp:api/handler                │
│                                             │
│  ┌─────────────────────────────────────┐    │
│  │      Core Module (lowered Rust)     │    │
│  │  Linear Memory: [................]  │    │
│  └─────────────────────────────────────┘    │
└─────────────────────────────────────────────┘

WIT: WebAssembly Interface Types

WIT is a purpose-built interface definition language. It is intentionally simple and easy to parse — designed to be read by both humans and code generators.

Basic Structure: Packages, Interfaces, Worlds

// A WIT file belongs to a package
// Format: namespace:name@version
package myorg:blog-platform@0.1.0;

// An interface is a named collection of types and functions
interface posts {
    // Type definitions
    record post-summary {
        id: string,
        title: string,
        slug: string,
        published-at: option<u64>,
        tag-count: u32,
    }

    record post-content {
        summary: post-summary,
        body: string,
        tags: list<string>,
        author: string,
    }

    variant post-error {
        not-found(string),
        forbidden,
        invalid-input(string),
        internal(string),
    }

    // Function definitions
    get-post: func(slug: string) -> result<post-content, post-error>;
    list-posts: func(limit: u32, offset: u32) -> list<post-summary>;
    create-post: func(content: post-content) -> result<string, post-error>;
    delete-post: func(id: string) -> result<_, post-error>;
}

// A world describes a complete component's interface:
// what it imports and what it exports
world blog-handler {
    // Import system interfaces from WASI
    import wasi:http/types@0.2.0;
    import wasi:logging/logging@0.1.0;

    // Import our application interface
    import posts;

    // Export the HTTP handler interface
    export wasi:http/incoming-handler@0.2.0;
}

The package declaration at the top (myorg:blog-platform@0.1.0) gives the package a unique identity. The namespace (myorg) and name (blog-platform) together identify the package. The version (0.1.0) follows semver.

An interface block defines a named group of types and functions. An interface can be imported or exported by a world.

A world is the complete description of a component’s contract with the outside world. import means the component needs this from the runtime or from other components. export means the component provides this for the runtime or other components to call.

The WIT Type System

WIT has a rich type system that covers all common data patterns:

package myorg:type-showcase@0.1.0;

interface all-the-types {
    // Primitive types
    demo-primitives: func(
        a: bool,
        b: u8,
        c: u16,
        d: u32,
        e: u64,
        f: s8,      // signed 8-bit
        g: s16,
        h: s32,
        i: s64,
        j: f32,
        k: f64,
        l: char,    // Unicode scalar value
        m: string,
    ) -> _;

    // Compound types
    demo-compounds: func(
        a: list<u8>,             // byte array / buffer
        b: list<string>,         // list of strings
        c: tuple<string, u32>,   // fixed-size heterogeneous tuple
        d: option<string>,       // nullable/optional value
        e: result<string, string>, // success or error
        f: result<u32, _>,       // result with no error payload
        g: result<_, string>,    // result with no success payload
    ) -> _;

    // Named compound types
    record point {
        x: f64,
        y: f64,
    }

    record person {
        name: string,
        age: u32,
        email: option<string>,
        roles: list<string>,
    }

    // Variant: like a Rust enum with payloads
    variant shape {
        circle(f64),             // radius
        rectangle(tuple<f64, f64>), // width, height
        polygon(list<point>),    // vertices
        unknown,                 // no payload
    }

    // Enum: variant with no payloads (C-style enum)
    enum log-level {
        trace,
        debug,
        info,
        warn,
        error,
    }

    // Flags: a bitfield / set of boolean flags
    flags permissions {
        read,
        write,
        execute,
        admin,
    }

    // Type aliases
    type byte-array = list<u8>;
    type error-message = string;
    type maybe-string = option<string>;

    // Functions using the above types
    compute-area: func(s: shape) -> f64;
    check-permissions: func(p: permissions, required: permissions) -> bool;
    log-message: func(level: log-level, msg: string) -> _;
}

Resources: Handles to Stateful Objects

Resources are the WIT mechanism for representing stateful, handle-based objects — things that have identity and lifetime, like open file handles, database connections, or active streams:

package myorg:database@0.1.0;

interface db {
    // A resource is like a class with methods
    resource connection {
        // Constructor (returns an owned handle)
        constructor(dsn: string);

        // Instance methods
        execute: func(sql: string, params: list<string>) -> result<u64, string>;
        query: func(sql: string, params: list<string>) -> result<list<row>, string>;

        // Transaction management
        begin: func() -> result<transaction, string>;

        // Check if connection is alive
        ping: func() -> bool;
    }

    resource transaction {
        execute: func(sql: string, params: list<string>) -> result<u64, string>;
        commit: func() -> result<_, string>;
        rollback: func() -> result<_, string>;
    }

    record row {
        columns: list<tuple<string, column-value>>,
    }

    variant column-value {
        null,
        integer(s64),
        real(f64),
        text(string),
        blob(list<u8>),
    }
}

world database-service {
    export db;
}

Resources are reference-counted handles. When a resource goes out of scope (determined by the Canonical ABI’s drop mechanism), the component that owns the resource can perform cleanup. Resources passed across component boundaries become “borrowed” handles — the receiving component can call methods on them but does not own them and cannot hold onto them after the call returns (unless it explicitly duplicates the resource, if supported).

Using Interfaces Across Files

Larger WIT definitions are typically split across multiple files using use:

// wit/types.wit
package myorg:platform@0.2.0;

interface common-types {
    type user-id = string;
    type timestamp = u64;

    record pagination {
        limit: u32,
        offset: u32,
    }

    variant api-error {
        not-found,
        unauthorized,
        forbidden,
        bad-request(string),
        internal-error(string),
        rate-limited(u32), // retry-after seconds
    }
}
// wit/users.wit
package myorg:platform@0.2.0;

// Import types from another interface in the same package
use common-types.{user-id, timestamp, api-error, pagination};

interface users {
    record user {
        id: user-id,
        username: string,
        email: string,
        created-at: timestamp,
        last-login: option<timestamp>,
    }

    get-user: func(id: user-id) -> result<user, api-error>;
    list-users: func(page: pagination) -> result<list<user>, api-error>;
    update-email: func(id: user-id, email: string) -> result<_, api-error>;
    delete-user: func(id: user-id) -> result<_, api-error>;
}
// wit/world.wit
package myorg:platform@0.2.0;

// Import interfaces from other packages using full path notation
use wasi:http/types@0.2.0.{incoming-request, outgoing-response};

world user-service {
    import wasi:http/types@0.2.0;
    import wasi:logging/logging@0.1.0;
    import wasi:keyvalue/store@0.2.0-draft;

    export users;
    export wasi:http/incoming-handler@0.2.0;
}

The directory structure for this package:

wit/
├── deps/
│   ├── wasi-http/
│   │   └── types.wit
│   ├── wasi-logging/
│   │   └── logging.wit
│   └── wasi-keyvalue/
│       └── store.wit
├── types.wit
├── users.wit
└── world.wit

The deps/ directory contains WIT files for external packages you depend on. The wasm-tools and cargo-component tooling understands this layout.


wasm-tools and the Toolchain

wasm-tools is the Swiss Army knife of the Component Model toolchain. It is a CLI built and maintained by the Bytecode Alliance.

Installation

1
2
3
4
5
6
7
8
9
# Via cargo
cargo install wasm-tools

# Via GitHub releases (pre-built binary)
curl -L https://github.com/bytecodealliance/wasm-tools/releases/latest/download/wasm-tools-linux-x86_64.tar.gz \
  | tar -xzf - -C ~/.local/bin/

wasm-tools --version
# wasm-tools 1.215.0

Core wasm-tools Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# Validate a Wasm binary or component
wasm-tools validate my-component.wasm
wasm-tools validate --features component-model my-component.wasm

# Print the text format (WAT or WIT-based component text)
wasm-tools print my-component.wasm
wasm-tools print my-component.wasm > my-component.wat

# Inspect component metadata and imports/exports
wasm-tools component wit my-component.wasm
# Prints the WIT interfaces embedded in the component binary

# Inspect a core module's imports/exports
wasm-tools dump my-module.wasm

# Convert between formats
wasm-tools parse my-component.wat -o my-component.wasm   # WAT → Wasm
wasm-tools print my-component.wasm -o my-component.wat   # Wasm → WAT

# Strip debug info (reduce binary size)
wasm-tools strip my-component.wasm -o my-component-stripped.wasm

# Check metadata
wasm-tools metadata show my-component.wasm

# Embed metadata into a component
wasm-tools metadata add my-component.wasm \
  --name "my-service" \
  --language rust \
  --processed-by "cargo-component:0.13.0" \
  -o my-component-with-metadata.wasm

# Compose components (covered in depth later)
wasm-tools compose my-app.wasm -d dependency.wasm -o composed.wasm

wit-bindgen: Generating Language Bindings

wit-bindgen reads WIT files and generates source code in the target language that handles all the Canonical ABI machinery for you. You write normal-looking code; the generated bindings handle the integer-level ABI.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Install wit-bindgen CLI
cargo install wit-bindgen-cli

# Generate Rust bindings from a WIT world
wit-bindgen rust --world user-service wit/

# Generate C bindings
wit-bindgen c --world user-service wit/

# Generate Python bindings
wit-bindgen python --world user-service wit/

# Generate JavaScript/TypeScript bindings (via jco)
npx jco bindgen my-component.wasm -o js-bindings/

# Generate Go bindings (via wit-bindgen-go)
wit-bindgen-go generate --world user-service --out gen/ wit/

Rust: cargo-component

The primary Rust workflow uses cargo-component, which wraps cargo and handles the build pipeline automatically:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Install
cargo install cargo-component

# Create a new component
cargo component new --lib my-service
cd my-service

# Project structure:
# ├── Cargo.toml
# ├── src/
# │   └── lib.rs
# └── wit/
#     └── world.wit

# Build as a component (not just a core module)
cargo component build
cargo component build --release

# The output is a proper .wasm component binary
ls target/wasm32-wasip1/debug/my_service.wasm

The Cargo.toml for a component looks like:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
[package]
name = "my-service"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
# wit-bindgen generates code using this crate's macros
wit-bindgen = "0.26"
# Commonly needed for the WASI HTTP types
wasi = "0.13"

[package.metadata.component]
# Points cargo-component to the WIT world to implement
package = "myorg:platform"

[package.metadata.component.dependencies]
# External WIT package dependencies
"wasi:http" = "0.2.0"
"wasi:logging" = "0.1.0"

A minimal WIT world for a component:

// wit/world.wit
package myorg:my-service@0.1.0;

world my-service {
    // Import WASI interfaces we need
    import wasi:logging/logging@0.1.0;

    // Export our interface
    export run: func() -> result<_, string>;
}

The corresponding Rust implementation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// src/lib.rs
// cargo-component generates bindings and calls wit_bindgen::generate!
// which generates the Rust trait and type definitions
use bindings::exports::myorg::my_service::run::Guest;
use bindings::wasi::logging::logging::{log, Level};

// The generated bindings module (from wit-bindgen)
mod bindings;

// Our implementation struct
struct Component;

// Implement the generated trait for our world's exports
impl Guest for Component {
    fn run() -> Result<(), String> {
        log(Level::Info, "my-service", "Starting up");

        // Do work here
        do_work().map_err(|e| e.to_string())?;

        log(Level::Info, "my-service", "Done");
        Ok(())
    }
}

fn do_work() -> anyhow::Result<()> {
    // Application logic
    println!("Hello from a WASM component!");
    Ok(())
}

// Required macro — tells cargo-component which struct implements the world
bindings::export!(Component with_types_in bindings);

The bindings module is auto-generated by cargo component build. You never write the Canonical ABI marshaling code — the toolchain generates it from the WIT definition.

Python: componentize-py

1
2
3
4
5
6
7
8
9
# Install
pip install componentize-py

# Given a WIT world and a Python implementation, produce a component
componentize-py \
  --wit-path wit/ \
  --world my-service \
  componentize app \
  --output my-service.wasm
1
2
3
4
5
6
7
8
# app.py — implement the exported functions from the WIT world
# componentize-py generates Python stubs that you implement

import my_service  # generated stub module

class MyService(my_service.MyService):  # generated base class
    def run(self) -> None:
        print("Hello from Python component!")

For a more complex example with HTTP handling:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# http_app.py
from typing import Tuple
import poll_loop
from wasi.http.types import (
    IncomingRequest,
    ResponseOutparam,
    OutgoingResponse,
    Headers,
    OutgoingBody,
)
import http_handler

class HttpHandler(http_handler.HttpHandler):
    def handle(self, request: IncomingRequest, response_out: ResponseOutparam) -> None:
        # Read request method and path
        method = request.method()
        path = request.path_with_query()

        # Build response headers
        headers = Headers.from_list([
            (b"content-type", b"application/json"),
            (b"x-powered-by", b"componentize-py"),
        ])

        # Build response
        response = OutgoingResponse(headers)
        response.set_status_code(200)

        body = response.body()
        stream = body.write()
        stream.write(
            f'{{"method": "{method}", "path": "{path}"}}'.encode()
        )
        stream.flush()
        del stream

        OutgoingBody.finish(body, None)
        ResponseOutparam.set(response_out, response)

JavaScript/TypeScript: jco

jco (JavaScript Component Tools) handles the bidirectional bridge between the Component Model and JavaScript:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Install
npm install -g @bytecodealliance/jco @bytecodealliance/componentize-js

# Transpile a .wasm component to JavaScript + types for use in Node.js
jco transpile my-component.wasm -o dist/

# This generates:
# dist/my-component.js       — JavaScript module with Canonical ABI handling
# dist/my-component.d.ts     — TypeScript type definitions
# dist/my-component.core.wasm — The inner core module

# Run a component directly with jco
jco run my-component.wasm

# Build a JavaScript/TypeScript source into a component
npx componentize-js \
  --wit-path wit/ \
  --world-name my-world \
  --input my-component.js \
  --output my-component.wasm

TypeScript source for a component:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// my-component.ts
// Import the generated type stubs
import type { Run } from "./wit/world.js";

// Implement the exported interface
export const run: typeof Run = {
    run(): Result<void, string> {
        console.log("Hello from a TypeScript component!");
        return { tag: "ok", val: undefined };
    }
};

Go: wit-bindgen-go

Go support is newer but functional:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Install
go install go.bytecodealliance.org/cmd/wit-bindgen-go@latest

# Generate Go bindings
wit-bindgen-go generate --world my-service --out gen/ wit/

# The generated code lives in gen/
# Compile with TinyGo for the best results
tinygo build \
  -target=wasip2 \
  -wit-package myorg:my-service \
  -wit-world my-service \
  -o my-service.wasm \
  .
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// main.go
package main

import (
    // Generated bindings from wit-bindgen-go
    myservice "github.com/myorg/my-service/gen/myorg/my-service/my-service"
)

// Implement the exported interface
type MyServiceImpl struct{}

func (m *MyServiceImpl) Run() (result.Result[struct{}, string]) {
    println("Hello from a Go component!")
    return result.OK(struct{}{})
}

func init() {
    // Register our implementation
    myservice.SetExportsMyorgMyServiceMyService(&MyServiceImpl{})
}

// Required for TinyGo
func main() {}

wasmtime: The Reference Runtime

wasmtime is the reference implementation of the WebAssembly runtime from the Bytecode Alliance. It is written in Rust and supports the Component Model natively.

Installation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# Install CLI
curl https://wasmtime.dev/install.sh -sSf | bash
source ~/.bashrc   # or restart shell

wasmtime --version
# wasmtime-cli 24.0.0

# Run a component
wasmtime run my-component.wasm

# Run a component with CLI arguments
wasmtime run my-component.wasm -- arg1 arg2

# Grant filesystem capabilities
wasmtime run \
  --dir /data::/data \      # map host /data to /data inside component
  --dir /tmp \              # grant access to /tmp (same path inside)
  my-component.wasm

# Set environment variables
wasmtime run \
  --env DATABASE_URL=postgres://localhost/mydb \
  --env LOG_LEVEL=debug \
  my-component.wasm

# Limit memory
wasmtime run --max-memory-size 67108864 my-component.wasm  # 64MB

# Run with profiling
wasmtime run --profile=perfmap my-component.wasm

wasmtime serve: HTTP Components

A key feature of wasmtime is first-class support for running HTTP components — components that implement the wasi:http/incoming-handler interface:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Serve an HTTP component on a specific port
wasmtime serve --addr 0.0.0.0:8080 my-http-component.wasm

# With outbound HTTP allowed to specific hosts
wasmtime serve \
  --addr 0.0.0.0:8080 \
  --allow-ip 93.184.216.34 \   # example.com
  my-http-component.wasm

# Enable logging
wasmtime serve \
  --addr 0.0.0.0:8080 \
  -E RUST_LOG=debug \
  my-http-component.wasm

The component implements the standard WASI HTTP interface, which means it is portable — the same .wasm file that runs with wasmtime serve also runs in Spin, in Fermyon Cloud, in any WASI-HTTP-compatible host.

Embedding wasmtime in Rust

For applications that need to host WASM components (plugin systems, multi-tenant platforms, extensible services), you embed wasmtime as a library:

1
2
3
4
5
6
7
// Cargo.toml
[dependencies]
wasmtime = "24"
wasmtime-wasi = "24"
wasmtime-wasi-http = "24"
anyhow = "1"
tokio = { version = "1", features = ["full"] }
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// src/main.rs — embedding wasmtime to run a component
use wasmtime::{
    component::{bindgen, Component, Linker},
    Config, Engine, Store,
};
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};

// Generate host-side Rust bindings from the WIT world
// This macro reads the WIT file at compile time and generates
// the trait you implement and the types you use
bindgen!({
    world: "my-service",
    path: "wit/",
    // Generate async bindings (optional, for async components)
    async: false,
});

// Host state struct — holds WASI context and your application state
struct MyHostState {
    wasi: WasiCtx,
    table: ResourceTable,
    // Your application state can go here
    request_count: u64,
}

// Required: implement WasiView for your state type
impl WasiView for MyHostState {
    fn ctx(&mut self) -> &mut WasiCtx {
        &mut self.wasi
    }
    fn table(&mut self) -> &mut ResourceTable {
        &mut self.table
    }
}

fn main() -> anyhow::Result<()> {
    // Configure the engine
    let mut config = Config::new();
    config.wasm_component_model(true);
    // Enable async if you need it
    // config.async_support(true);

    let engine = Engine::new(&config)?;

    // Load the component binary
    let component = Component::from_file(&engine, "my-service.wasm")?;

    // Create a linker and add WASI imports
    let mut linker: Linker<MyHostState> = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    // Create host state with restricted capabilities
    let wasi = WasiCtxBuilder::new()
        .inherit_stdout()      // allow stdout
        .inherit_stderr()      // allow stderr
        // .preopened_dir(...)  // grant filesystem access
        // .env(...)            // grant env vars
        .build();

    let state = MyHostState {
        wasi,
        table: ResourceTable::new(),
        request_count: 0,
    };

    let mut store = Store::new(&engine, state);

    // Instantiate the component — this resolves all imports
    let (instance, _) = MyService::instantiate(&mut store, &component, &linker)?;

    // Call the exported function — fully typed, no manual marshaling
    match instance.call_run(&mut store)? {
        Ok(()) => println!("Component ran successfully"),
        Err(msg) => eprintln!("Component returned error: {}", msg),
    }

    println!("Request count: {}", store.data().request_count);
    Ok(())
}

Embedding wasmtime for a Plugin System

A more realistic embedding: a plugin host that loads multiple plugin components, each sandboxed:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
use std::collections::HashMap;
use wasmtime::{
    component::{bindgen, Component, Linker},
    Config, Engine, Store,
};
use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView};

// WIT for the plugin interface
// plugins implement this world:
//
// world plugin {
//     import host-log: func(msg: string) -> _;
//     export process: func(input: string) -> result<string, string>;
// }

bindgen!({
    world: "plugin",
    path: "wit/plugin.wit",
});

struct PluginState {
    wasi: WasiCtx,
    table: ResourceTable,
    plugin_name: String,
    log_buffer: Vec<String>,
}

impl WasiView for PluginState {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

// Implement the host-provided import (host-log)
impl PluginImports for PluginState {
    fn host_log(&mut self, msg: String) {
        println!("[plugin:{}] {}", self.plugin_name, msg);
        self.log_buffer.push(msg);
    }
}

struct PluginHost {
    engine: Engine,
    linker: Linker<PluginState>,
    plugins: HashMap<String, Component>,
}

impl PluginHost {
    fn new() -> anyhow::Result<Self> {
        let mut config = Config::new();
        config.wasm_component_model(true);
        let engine = Engine::new(&config)?;

        let mut linker: Linker<PluginState> = Linker::new(&engine);
        wasmtime_wasi::add_to_linker_sync(&mut linker)?;

        // Add the host functions that plugins can call
        Plugin::add_to_linker(&mut linker, |state| state)?;

        Ok(Self {
            engine,
            linker,
            plugins: HashMap::new(),
        })
    }

    fn load_plugin(&mut self, name: &str, wasm_bytes: &[u8]) -> anyhow::Result<()> {
        let component = Component::from_binary(&self.engine, wasm_bytes)?;
        self.plugins.insert(name.to_string(), component);
        Ok(())
    }

    fn run_plugin(&self, name: &str, input: &str) -> anyhow::Result<Result<String, String>> {
        let component = self.plugins.get(name)
            .ok_or_else(|| anyhow::anyhow!("Plugin '{}' not found", name))?;

        // Each invocation gets a fresh store — full isolation between calls
        let wasi = WasiCtxBuilder::new()
            // No filesystem, no network, no env — just compute
            .build();

        let state = PluginState {
            wasi,
            table: ResourceTable::new(),
            plugin_name: name.to_string(),
            log_buffer: Vec::new(),
        };

        let mut store = Store::new(&self.engine, state);

        // Add resource limits to prevent runaway plugins
        store.set_fuel(1_000_000)?;  // CPU budget in fuel units
        // Memory limits configured in the engine config

        let (plugin, _) = Plugin::instantiate(&mut store, component, &self.linker)?;

        // Call the plugin — returns Result<String, String>
        Ok(plugin.call_process(&mut store, input)?)
    }
}

fn main() -> anyhow::Result<()> {
    let mut host = PluginHost::new()?;

    // Load plugin from disk (could be from a database, network, etc.)
    let plugin_bytes = std::fs::read("plugins/markdown-renderer.wasm")?;
    host.load_plugin("markdown", &plugin_bytes)?;

    let plugin_bytes = std::fs::read("plugins/json-transformer.wasm")?;
    host.load_plugin("json-transform", &plugin_bytes)?;

    // Run plugins — completely isolated, resource-bounded
    let result = host.run_plugin("markdown", "# Hello\n\nThis is **bold**.")?;
    match result {
        Ok(html) => println!("Rendered HTML: {}", html),
        Err(e) => eprintln!("Plugin error: {}", e),
    }

    Ok(())
}

This pattern is exactly what production plugin systems need: untrusted code, bounded resources, no capability leakage, fully typed interfaces.

Embedding wasmtime in Python

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# pip install wasmtime
from wasmtime import Config, Engine, Store, Component, Linker
from wasmtime.bindgen import MyService  # generated by jco or similar

config = Config()
config.wasm_component_model = True

engine = Engine(config)
store = Store(engine)

# Load and instantiate the component
with open("my-service.wasm", "rb") as f:
    component_bytes = f.read()

component = Component(engine, component_bytes)
linker = Linker(engine)

# Add WASI to the linker
from wasmtime import WasiConfig
wasi = WasiConfig()
wasi.inherit_stdout()
store.set_wasi(wasi)
linker.define_wasi()

# Instantiate and call
instance = linker.instantiate(store, component)
run_func = instance.exports(store)["run"]
result = run_func(store)
print(f"Component returned: {result}")

Fermyon Spin

Spin by Fermyon is the highest-level framework for building Component Model applications. It wraps the machinery of components, WASI, and wasmtime into a developer experience focused on building HTTP microservices and event-driven applications.

Installation and Setup

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# Install Spin
curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash
sudo mv spin /usr/local/bin/

spin --version
# spin 3.1.0

# Install language targets and templates
spin templates install --git https://github.com/fermyon/spin
spin templates install --git https://github.com/fermyon/spin-python-sdk
spin templates install --git https://github.com/fermyon/spin-js-sdk

# List available templates
spin templates list
# NAME                    DESCRIPTION
# http-rust               HTTP handler using the Rust SDK
# http-go                 HTTP handler using the Go SDK
# http-python             HTTP handler using the Python SDK
# http-js                 HTTP handler using the JavaScript SDK
# http-ts                 HTTP handler using the TypeScript SDK
# redis-rust              Redis message handler
# ...

# Install language plugins
spin plugins install js2wasm
spin plugins install py2wasm

spin.toml Deep Dive

The spin.toml manifest is the center of a Spin application:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
# spin.toml
spin_manifest_version = 2

[application]
name = "blog-api"
version = "0.2.0"
description = "Blog platform REST API built with Spin"
authors = ["Your Name <you@example.com>"]

# HTTP trigger — routes incoming HTTP requests to components
[[trigger.http]]
route = "/api/posts/..."
component = "posts-handler"

[[trigger.http]]
route = "/api/users/..."
component = "users-handler"

[[trigger.http]]
route = "/health"
component = "health-check"

# Redis trigger — handle messages from a Redis pub/sub channel
[[trigger.redis]]
channel = "events"
component = "event-processor"
address = "redis://redis.internal:6379"

# First component: posts handler
[component.posts-handler]
source = "posts/target/wasm32-wasip1/release/posts_handler.wasm"
description = "Handles all /api/posts/* routes"

# Outbound network capability — explicit allowlist
allowed_outbound_hosts = [
    "https://cdn.example.com",
    "sqlite://blog.db",
]

# Key-value store access
[component.posts-handler.key_value_stores]
default = { label = "posts-cache" }

# SQLite database access
[component.posts-handler.sqlite_databases]
default = { label = "blog-db" }

# Component-level variables (can reference env vars at deploy time)
[component.posts-handler.variables]
auth_secret = { required = true }
cache_ttl = { default = "300" }
max_results = { default = "50" }

# Build configuration
[component.posts-handler.build]
command = "cargo component build --release"
workdir = "posts"
watch = ["posts/src/**/*.rs", "posts/Cargo.toml", "posts/wit/**"]

# Second component: users handler
[component.users-handler]
source = "users/target/wasm32-wasip1/release/users_handler.wasm"
allowed_outbound_hosts = [
    "https://auth.example.com",
]

[component.users-handler.key_value_stores]
sessions = { label = "user-sessions" }

[component.users-handler.build]
command = "cargo component build --release"
workdir = "users"

# Third component: health check (minimal permissions)
[component.health-check]
source = "health/target/wasm32-wasip1/release/health.wasm"
# No allowed_outbound_hosts — can't make any outbound calls
# No key-value store — can't persist anything
# This component can ONLY handle HTTP requests and return responses

[component.health-check.build]
command = "cargo component build --release"
workdir = "health"

# Redis handler component
[component.event-processor]
source = "events/target/wasm32-wasip1/release/event_processor.wasm"
allowed_outbound_hosts = [
    "https://webhooks.example.com",
    "postgres://db.internal:5432",
]

[component.event-processor.build]
command = "cargo component build --release"
workdir = "events"

The power of the allowed_outbound_hosts list is architectural: it makes the network capabilities of each component explicit in the manifest, not buried in code. You can audit the entire blast radius of a compromised component from a single file.

Spin HTTP Handler in Depth

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
// posts/src/lib.rs
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use spin_sdk::{
    http::{IntoResponse, Method, Params, Request, Response, Router},
    http_component,
    key_value::Store,
    sqlite::{Connection, Value},
    variables,
};

#[derive(Serialize, Deserialize, Debug, Clone)]
struct Post {
    id: String,
    title: String,
    slug: String,
    body: String,
    author: String,
    published: bool,
    created_at: u64,
    tags: Vec<String>,
}

#[derive(Deserialize, Debug)]
struct CreatePostRequest {
    title: String,
    body: String,
    author: String,
    tags: Vec<String>,
}

#[derive(Serialize)]
struct ApiError {
    error: String,
    code: u32,
}

fn error_response(status: u16, msg: &str) -> Response {
    Response::builder()
        .status(status)
        .header("Content-Type", "application/json")
        .body(serde_json::to_vec(&ApiError {
            error: msg.to_string(),
            code: status as u32,
        }).unwrap_or_default())
        .build()
}

#[http_component]
fn handle(req: Request) -> Response {
    let mut router = Router::new();
    router.get("/api/posts", list_posts);
    router.get("/api/posts/:slug", get_post);
    router.post("/api/posts", create_post);
    router.put("/api/posts/:slug", update_post);
    router.delete("/api/posts/:slug", delete_post);
    router.handle(req)
}

fn list_posts(req: Request, _params: Params) -> Result<impl IntoResponse> {
    // Try cache first
    let cache = Store::open_default()?;
    if let Ok(Some(cached)) = cache.get("all-posts") {
        return Ok(Response::builder()
            .status(200)
            .header("Content-Type", "application/json")
            .header("X-Cache", "HIT")
            .body(cached)
            .build());
    }

    // Query SQLite
    let conn = Connection::open_default()?;
    let rows = conn.execute(
        "SELECT id, title, slug, body, author, published, created_at FROM posts WHERE published = ? ORDER BY created_at DESC LIMIT ?",
        &[Value::Integer(1), Value::Integer(50)],
    )?;

    let mut posts = Vec::new();
    for row in rows.rows() {
        let id = row.get::<&str>(0)?.to_string();
        // Fetch tags for this post
        let tag_rows = conn.execute(
            "SELECT tag FROM post_tags WHERE post_id = ?",
            &[Value::Text(id.clone())],
        )?;
        let tags: Vec<String> = tag_rows.rows()
            .map(|r| r.get::<&str>(0).unwrap_or("").to_string())
            .collect();

        posts.push(Post {
            id,
            title: row.get::<&str>(1)?.to_string(),
            slug: row.get::<&str>(2)?.to_string(),
            body: row.get::<&str>(3)?.to_string(),
            author: row.get::<&str>(4)?.to_string(),
            published: row.get::<i64>(5)? != 0,
            created_at: row.get::<i64>(6)? as u64,
            tags,
        });
    }

    let json = serde_json::to_vec(&posts)?;

    // Cache for 5 minutes (using the TTL variable from spin.toml)
    let ttl: u32 = variables::get("cache_ttl")
        .unwrap_or_default()
        .parse()
        .unwrap_or(300);
    cache.set_json("all-posts", &posts)?;

    Ok(Response::builder()
        .status(200)
        .header("Content-Type", "application/json")
        .header("X-Cache", "MISS")
        .body(json)
        .build())
}

fn create_post(req: Request, _params: Params) -> Result<impl IntoResponse> {
    // Verify auth
    let auth_secret = variables::get("auth_secret")
        .context("auth_secret variable not set")?;
    let bearer = req.header("Authorization")
        .and_then(|h| h.as_str())
        .and_then(|s| s.strip_prefix("Bearer "));

    if bearer != Some(&auth_secret) {
        return Ok(error_response(401, "Unauthorized"));
    }

    // Parse request body
    let create_req: CreatePostRequest = serde_json::from_slice(req.body())
        .map_err(|e| anyhow::anyhow!("Invalid JSON: {}", e))?;

    // Validate
    if create_req.title.is_empty() {
        return Ok(error_response(400, "title is required"));
    }
    if create_req.body.is_empty() {
        return Ok(error_response(400, "body is required"));
    }

    let id = uuid_v4();  // deterministic from wasi:random
    let slug = slugify(&create_req.title);
    let now = current_timestamp();  // from wasi:clocks

    let conn = Connection::open_default()?;
    conn.execute(
        "INSERT INTO posts (id, title, slug, body, author, published, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
        &[
            Value::Text(id.clone()),
            Value::Text(create_req.title.clone()),
            Value::Text(slug.clone()),
            Value::Text(create_req.body.clone()),
            Value::Text(create_req.author.clone()),
            Value::Integer(0),  // not published yet
            Value::Integer(now as i64),
        ],
    )?;

    for tag in &create_req.tags {
        conn.execute(
            "INSERT OR IGNORE INTO post_tags (post_id, tag) VALUES (?, ?)",
            &[Value::Text(id.clone()), Value::Text(tag.clone())],
        )?;
    }

    // Invalidate cache
    let cache = Store::open_default()?;
    cache.delete("all-posts")?;

    let post = Post {
        id,
        title: create_req.title,
        slug,
        body: create_req.body,
        author: create_req.author,
        published: false,
        created_at: now,
        tags: create_req.tags,
    };

    Ok(Response::builder()
        .status(201)
        .header("Content-Type", "application/json")
        .body(serde_json::to_vec(&post)?)
        .build())
}

// Helper functions using WASI interfaces
fn uuid_v4() -> String {
    use spin_sdk::random::get_random_bytes;
    let bytes = get_random_bytes(16);
    format!(
        "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
        u32::from_le_bytes(bytes[0..4].try_into().unwrap()),
        u16::from_le_bytes(bytes[4..6].try_into().unwrap()),
        u16::from_le_bytes(bytes[6..8].try_into().unwrap()) & 0x0fff,
        (u16::from_le_bytes(bytes[8..10].try_into().unwrap()) & 0x3fff) | 0x8000,
        {
            let b = &bytes[10..16];
            ((b[0] as u64) << 40) | ((b[1] as u64) << 32) | ((b[2] as u64) << 24) |
            ((b[3] as u64) << 16) | ((b[4] as u64) << 8) | (b[5] as u64)
        }
    )
}

fn current_timestamp() -> u64 {
    use spin_sdk::clocks::now;
    now().seconds
}

fn slugify(title: &str) -> String {
    title.to_lowercase()
        .chars()
        .map(|c| if c.is_alphanumeric() { c } else { '-' })
        .collect::<String>()
        .split('-')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

// update_post and delete_post follow the same pattern...
fn update_post(_req: Request, _params: Params) -> Result<impl IntoResponse> {
    Ok(Response::builder().status(501).body("not implemented").build())
}

fn delete_post(_req: Request, params: Params) -> Result<impl IntoResponse> {
    let slug = params.get("slug").unwrap_or("");
    let conn = Connection::open_default()?;
    conn.execute("DELETE FROM posts WHERE slug = ?", &[Value::Text(slug.to_string())])?;
    let cache = Store::open_default()?;
    cache.delete("all-posts")?;
    Ok(Response::builder().status(204).build())
}

Spin Triggers: Beyond HTTP

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# MQTT trigger for IoT event processing
[[trigger.mqtt]]
topic = "sensors/temperature"
component = "temp-processor"
address = "mqtt://mqtt-broker.internal:1883"
qos = "at-least-once"

# Cron-like schedule trigger (via spin-trigger-timer plugin)
[[trigger.timer]]
interval = "60s"
component = "metrics-collector"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Redis trigger handler
use spin_sdk::{redis_component, redis::Payload};

#[redis_component]
fn handle_message(message: Payload) -> anyhow::Result<()> {
    let body = String::from_utf8(message.to_vec())?;
    println!("Processing event: {}", body);

    // Parse the event, do work, call other services
    let event: serde_json::Value = serde_json::from_str(&body)?;

    match event["type"].as_str() {
        Some("user.signup") => process_signup(&event)?,
        Some("post.published") => process_publish(&event)?,
        Some(t) => eprintln!("Unknown event type: {}", t),
        None => eprintln!("Missing event type"),
    }

    Ok(())
}

fn process_signup(event: &serde_json::Value) -> anyhow::Result<()> {
    // Send welcome email via outbound HTTP to email service
    use spin_sdk::http::{send, Request, Method};
    let email = event["email"].as_str().unwrap_or("");
    let _response = send(
        Request::builder()
            .method(Method::Post)
            .uri("https://email.example.com/send")
            .header("Content-Type", "application/json")
            .body(serde_json::json!({"to": email, "template": "welcome"}).to_string())
            .build()
    )?;
    Ok(())
}

fn process_publish(_event: &serde_json::Value) -> anyhow::Result<()> {
    // Invalidate CDN cache, trigger reindex, etc.
    Ok(())
}

Deploying Spin Applications

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# Local development
spin build
spin up
# Spin is listening on http://127.0.0.1:3000

# Watch mode — rebuild on source changes
spin watch

# Deploy to Fermyon Cloud (free tier: 10 components, unlimited requests)
spin login
spin deploy
# Output:
# Uploading blog-api version 0.2.0+r12345...
# Deploying...
# Waiting for application to become ready... ready
# Available Routes:
#   posts-handler: https://blog-api-xyz.fermyon.app/api/posts (and wildcard)
#   users-handler: https://blog-api-xyz.fermyon.app/api/users (and wildcard)
#   health-check: https://blog-api-xyz.fermyon.app/health

# Self-hosted Spin (just the binary, no Kubernetes required)
spin up --listen 0.0.0.0:8080 --tls-cert cert.pem --tls-key key.pem

# Deploy to SpinKube (Kubernetes)
# First, push to OCI registry
spin registry push ghcr.io/myorg/blog-api:v0.2.0

# Then create a SpinApp resource
cat > spinapp.yaml << 'EOF'
apiVersion: core.spinkube.dev/v1alpha1
kind: SpinApp
metadata:
  name: blog-api
  namespace: production
spec:
  image: ghcr.io/myorg/blog-api:v0.2.0
  replicas: 5
  executor: containerd-shim-spin
  resources:
    limits:
      cpu: 200m
      memory: 128Mi
  variables:
    - name: auth_secret
      valueFrom:
        secretKeyRef:
          name: blog-api-secrets
          key: auth-secret
EOF
kubectl apply -f spinapp.yaml

# Scale
kubectl scale spinapp blog-api --replicas=20

Composing Components

Component composition is the mechanism for linking components together into a larger system at the WASM level — before any runtime is involved. This is fundamentally different from service composition (where services communicate over a network) or library linking (where they share memory). Composed components maintain isolation but call each other through the Canonical ABI with no network overhead.

The Composition Model

When you compose components, you are creating a new component that contains both (or all) of the input components. The imports of one component are satisfied by the exports of another. The result is a single .wasm file that is self-contained.

┌─────────────────────────────────────────────────────────────────┐
│                    Composed Component                           │
│                                                                 │
│  ┌─────────────────────┐    ┌─────────────────────────────┐    │
│  │  HTTP Handler       │    │  Database Component         │    │
│  │  (Rust)             │    │  (Go)                       │    │
│  │                     │    │                             │    │
│  │  imports:           │───▶│  exports:                   │    │
│  │    myapp:db/store   │    │    myapp:db/store           │    │
│  │                     │    │                             │    │
│  │  exports:           │    │  imports:                   │    │
│  │    wasi:http/...    │    │    wasi:filesystem/...      │    │
│  └─────────────────────┘    └─────────────────────────────┘    │
│                                                                 │
│  Unsatisfied imports (from outer world):                        │
│    wasi:http/incoming-handler                                   │
│    wasi:filesystem/preopens                                     │
└─────────────────────────────────────────────────────────────────┘

Using wasm-tools compose

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Basic composition: the http-handler imports myapp:db/store,
# which is exported by db-component
wasm-tools compose \
  http-handler.wasm \
  -d db-component.wasm \
  -o composed-app.wasm

# Compose multiple dependencies
wasm-tools compose \
  http-handler.wasm \
  -d db-component.wasm \
  -d cache-component.wasm \
  -d auth-component.wasm \
  -o composed-app.wasm

# Validate the result
wasm-tools validate composed-app.wasm

# Inspect what was composed
wasm-tools component wit composed-app.wasm

A Practical Composition Example

Suppose you have three components:

  1. http-handler (Rust): Handles HTTP requests, needs a key-value store and a logger
  2. kv-store (Go): Implements the key-value store interface, backed by in-memory storage
  3. structured-logger (TypeScript): Implements the logger interface, outputs JSON

Their WIT interfaces:

// wit/kv.wit — the key-value interface
package myapp:kv@0.1.0;

interface store {
    get: func(key: string) -> option<list<u8>>;
    set: func(key: string, value: list<u8>) -> result<_, string>;
    delete: func(key: string) -> result<bool, string>;
    list-keys: func(prefix: option<string>) -> list<string>;
}
// wit/logging.wit
package myapp:logging@0.1.0;

interface logger {
    enum level { trace, debug, info, warn, error }
    log: func(level: level, component: string, message: string, fields: list<tuple<string, string>>) -> _;
}
// wit/http-handler-world.wit
package myapp:http-handler@0.1.0;

world http-handler {
    import myapp:kv/store@0.1.0;
    import myapp:logging/logger@0.1.0;
    export wasi:http/incoming-handler@0.2.0;
}

Build each component separately:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Build the Rust HTTP handler
cd http-handler && cargo component build --release
# Produces: http-handler.wasm (has unsatisfied imports: myapp:kv/store, myapp:logging/logger)

# Build the Go KV store
cd kv-store && tinygo build -target=wasip2 -o kv-store.wasm .
# Produces: kv-store.wasm (exports: myapp:kv/store)

# Build the TypeScript logger
cd structured-logger
npx componentize-js \
  --wit-path ../wit \
  --world-name logger-component \
  --input logger.js \
  --output structured-logger.wasm
# Produces: structured-logger.wasm (exports: myapp:logging/logger)

Compose them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Wire everything together
wasm-tools compose \
  http-handler/target/wasm32-wasip1/release/http_handler.wasm \
  -d kv-store/kv-store.wasm \
  -d structured-logger/structured-logger.wasm \
  -o deployed/blog-api.wasm

# The result is a single .wasm file that:
# - Contains all three components' core modules
# - Has all internal interfaces wired together
# - Only imports the WASI interfaces from the host (wasi:http, etc.)
# - Is completely self-contained and deployable

wasm-tools validate deployed/blog-api.wasm

# Run it with wasmtime serve
wasmtime serve --addr 0.0.0.0:8080 deployed/blog-api.wasm

# Or deploy with Spin (the wasm file can be used directly in spin.toml)
# Or push to an OCI registry for SpinKube

Selective Composition with a Configuration File

For complex compositions, wasm-tools compose supports a YAML configuration file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# compose.yaml
component: http-handler/target/wasm32-wasip1/release/http_handler.wasm

definitions:
  - kv-store/kv-store.wasm
  - structured-logger/structured-logger.wasm
  - auth-service/auth-service.wasm

# Explicitly wire interfaces (optional — auto-wiring is the default)
instantiations:
  kv:
    component: kv-store/kv-store.wasm
  logger:
    component: structured-logger/structured-logger.wasm
  auth:
    component: auth-service/auth-service.wasm

# The http-handler imports are satisfied by:
# myapp:kv/store        -> kv instance
# myapp:logging/logger  -> logger instance
# myapp:auth/verifier   -> auth instance
1
wasm-tools compose --config compose.yaml -o deployed/blog-api.wasm

Dynamic Composition at Runtime

wasm-tools compose produces static compositions baked into the binary. For dynamic composition — loading different implementations at runtime — you use the wasmtime embedding API with dynamic linking:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Runtime composition: swap implementations without recompiling
use wasmtime::{
    component::{Component, Linker},
    Config, Engine, Store,
};

fn load_configured_app(
    engine: &Engine,
    handler_path: &str,
    kv_impl_path: &str,  // Could be "redis-kv.wasm" or "memory-kv.wasm"
    logger_impl_path: &str,
) -> anyhow::Result<()> {
    let handler = Component::from_file(engine, handler_path)?;
    let kv_impl = Component::from_file(engine, kv_impl_path)?;
    let logger_impl = Component::from_file(engine, logger_impl_path)?;

    // The linker satisfies the handler's imports using
    // the specified implementations
    // (full dynamic linking API is still stabilizing in wasmtime)
    // See: wasmtime::component::Linker and ResourceTable

    Ok(())
}

Plugin Systems with the Component Model

Plugin systems are arguably the most compelling near-term use case for the Component Model. The requirements for a plugin system are almost perfectly matched by what components provide:

Plugin System Requirement Component Model Feature
Plugins in any language WIT bindings for Rust, Go, Python, JS, C
Sandboxed execution Shared-nothing isolation + capability grants
Typed interfaces WIT interface definitions
Version compatibility WIT package versioning
Runtime loading Component binary format + wasmtime API
Resource limits wasmtime Store fuel + memory limits
Hot reloading Fresh Store per invocation

Designing a Plugin Interface in WIT

// wit/plugin-api.wit
package myapp:plugin-api@1.0.0;

// What plugins can call from the host
interface host-capabilities {
    // Logging (sandboxed — plugins can't write to disk directly)
    log-info: func(message: string) -> _;
    log-warn: func(message: string) -> _;
    log-error: func(message: string) -> _;

    // Key-value storage (scoped to the plugin's namespace)
    kv-get: func(key: string) -> option<list<u8>>;
    kv-set: func(key: string, value: list<u8>) -> result<_, string>;

    // Outbound HTTP (controlled by host policy)
    http-fetch: func(url: string, headers: list<tuple<string, string>>) -> result<http-response, string>;

    record http-response {
        status: u16,
        headers: list<tuple<string, string>>,
        body: list<u8>,
    }
}

// What plugins must implement (exports to the host)
interface plugin-lifecycle {
    // Called once when the plugin is loaded
    on-load: func(config: list<tuple<string, string>>) -> result<_, string>;

    // Plugin metadata
    name: func() -> string;
    version: func() -> string;
    description: func() -> string;
}

// The event-processing interface (what makes it a processing plugin)
interface event-processor {
    record event {
        id: string,
        event-type: string,
        source: string,
        timestamp: u64,
        payload: list<u8>,
    }

    record processing-result {
        success: bool,
        output-events: list<event>,
        error-message: option<string>,
        metadata: list<tuple<string, string>>,
    }

    // Process an event and optionally emit new events
    process: func(event: event) -> processing-result;
}

// The world that plugin components implement
world event-processing-plugin {
    import host-capabilities;
    export plugin-lifecycle;
    export event-processor;
}

// The world that the host application implements (inverse perspective)
world plugin-host {
    export host-capabilities;
    import plugin-lifecycle;
    import event-processor;
}

Building a Plugin in Rust

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
// my-plugin/src/lib.rs
use bindings::exports::myapp::plugin_api::event_processor::{Event, Guest as ProcessorGuest, ProcessingResult};
use bindings::exports::myapp::plugin_api::plugin_lifecycle::Guest as LifecycleGuest;
use bindings::myapp::plugin_api::host_capabilities::{http_fetch, kv_get, kv_set, log_info};

mod bindings;

struct MyPlugin;

impl LifecycleGuest for MyPlugin {
    fn on_load(config: Vec<(String, String)>) -> Result<(), String> {
        log_info(&format!("my-plugin v{} loading with {} config entries",
            MyPlugin::version(), config.len()));

        // Validate config
        let required_keys = ["api_endpoint", "timeout_ms"];
        for key in &required_keys {
            if !config.iter().any(|(k, _)| k == key) {
                return Err(format!("Missing required config key: {}", key));
            }
        }

        // Store config in plugin-scoped KV
        for (k, v) in &config {
            kv_set(&format!("config:{}", k), v.as_bytes())
                .map_err(|e| format!("Failed to store config: {}", e))?;
        }

        log_info("my-plugin loaded successfully");
        Ok(())
    }

    fn name() -> String { "my-plugin".to_string() }
    fn version() -> String { "1.0.0".to_string() }
    fn description() -> String { "Example event processing plugin".to_string() }
}

impl ProcessorGuest for MyPlugin {
    fn process(event: Event) -> ProcessingResult {
        log_info(&format!("Processing event: {} (type: {})", event.id, event.event_type));

        // Access plugin-scoped KV storage
        let endpoint = kv_get("config:api_endpoint")
            .and_then(|bytes| String::from_utf8(bytes).ok())
            .unwrap_or_else(|| "https://default.api.example.com".to_string());

        // Parse payload
        let payload_str = String::from_utf8(event.payload.clone())
            .unwrap_or_default();

        // Optionally make outbound HTTP calls (if host policy allows)
        let enriched_data = if event.event_type == "user.action" {
            match http_fetch(
                &format!("{}/enrich/{}", endpoint, event.source),
                &[("Content-Type".to_string(), "application/json".to_string())],
            ) {
                Ok(response) if response.status == 200 => {
                    Some(response.body)
                }
                Ok(response) => {
                    log_warn(&format!("Enrichment API returned {}", response.status));
                    None
                }
                Err(e) => {
                    log_warn(&format!("Enrichment API error: {}", e));
                    None
                }
            }
        } else {
            None
        };

        // Emit a transformed event
        let output_payload = serde_json::json!({
            "original": payload_str,
            "enriched": enriched_data.map(|b| String::from_utf8_lossy(&b).to_string()),
            "processed_by": "my-plugin/1.0.0",
        });

        ProcessingResult {
            success: true,
            output_events: vec![Event {
                id: format!("{}-processed", event.id),
                event_type: format!("{}.processed", event.event_type),
                source: "my-plugin".to_string(),
                timestamp: event.timestamp,
                payload: output_payload.to_string().into_bytes(),
            }],
            error_message: None,
            metadata: vec![("plugin".to_string(), "my-plugin".to_string())],
        }
    }
}

fn log_warn(msg: &str) {
    bindings::myapp::plugin_api::host_capabilities::log_warn(msg);
}

bindings::export!(MyPlugin with_types_in bindings);

Real-World Plugin Systems Using Components

Zed Editor: Zed (the high-performance code editor written in Rust) uses WebAssembly for its extension system. Extensions are .wasm components that implement Zed’s WIT interfaces. This lets any language with Wasm support write Zed extensions — the same binary works on macOS, Linux, and Windows without recompilation.

Extism: The Extism project provides a higher-level plugin framework built on the Component Model concepts. It supports Rust, Go, Python, JavaScript, Ruby, PHP, and more as both host languages and plugin languages.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Using Extism for plugins (higher-level API over Component Model concepts)
use extism::*;

fn main() -> anyhow::Result<()> {
    // Load a plugin from a file
    let plugin_bytes = std::fs::read("my-plugin.wasm")?;

    // Define what the plugin can call from the host
    let host_fn = host_fn!("http_get", |_plugin: CurrentPlugin, url: String| -> String {
        // Controlled HTTP access
        ureq::get(&url).call()?.into_string()?
    });

    let manifest = Manifest::new([Wasm::data(plugin_bytes)])
        .with_allowed_host("api.example.com");  // Network allowlist

    let mut plugin = Plugin::new(manifest, [host_fn], true)?;

    // Call the plugin function
    let result: String = plugin.call("process", "input data")?;
    println!("Plugin returned: {}", result);

    Ok(())
}

WASM Filters in Envoy/Istio: Envoy proxy supports WASM plugins for request/response processing. These are not full Component Model yet (they use an older WASM ABI), but they demonstrate the pattern at massive scale — these run on every request at the mesh layer.


The Polyglot Story

One of the headline promises of the Component Model is true language interoperability: a Python component calling a Rust component calling a Go component, all in the same process, all communicating through typed WIT interfaces.

A Polyglot Pipeline Example

Suppose you are building a document processing pipeline:

  • Rust component: HTTP ingestion, validation, routing (fastest for networking)
  • Python component: ML-based document classification (Python ML ecosystem)
  • Go component: Structured data extraction and storage (Go for ergonomics)

The WIT interfaces:

// wit/pipeline.wit
package myorg:docpipeline@0.1.0;

interface ingester {
    record raw-document {
        id: string,
        content-type: string,
        data: list<u8>,
        metadata: list<tuple<string, string>>,
    }
    ingest: func(doc: raw-document) -> result<string, string>; // returns doc-id
}

interface classifier {
    enum document-class {
        invoice,
        contract,
        report,
        email,
        unknown,
    }

    record classification-result {
        doc-id: string,
        class: document-class,
        confidence: f32,
        labels: list<tuple<string, f32>>,
    }

    classify: func(doc-id: string, content: list<u8>) -> result<classification-result, string>;
}

interface extractor {
    record extracted-data {
        doc-id: string,
        fields: list<tuple<string, string>>,
        tables: list<list<list<string>>>,
        errors: list<string>,
    }
    extract: func(doc-id: string, doc-class: string, content: list<u8>) -> result<extracted-data, string>;
    store: func(data: extracted-data) -> result<_, string>;
}

world pipeline-orchestrator {
    import classifier;
    import extractor;
    import wasi:http/types@0.2.0;
    export ingester;
    export wasi:http/incoming-handler@0.2.0;
}

world classifier-component {
    export classifier;
}

world extractor-component {
    export extractor;
    import wasi:filesystem/preopens@0.2.0;
}

The Rust orchestrator that calls both:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// orchestrator/src/lib.rs
use bindings::exports::myorg::docpipeline::ingester::{Guest, RawDocument};
use bindings::myorg::docpipeline::classifier::{classify, DocumentClass};
use bindings::myorg::docpipeline::extractor::{extract, store};

mod bindings;

struct Orchestrator;

impl Guest for Orchestrator {
    fn ingest(doc: RawDocument) -> Result<String, String> {
        let doc_id = doc.id.clone();

        // Call the Python classifier component
        // This is a direct function call — no HTTP, no serialization overhead
        let classification = classify(&doc_id, &doc.data)
            .map_err(|e| format!("Classification failed: {}", e))?;

        let class_name = match classification.class {
            DocumentClass::Invoice => "invoice",
            DocumentClass::Contract => "contract",
            DocumentClass::Report => "report",
            DocumentClass::Email => "email",
            DocumentClass::Unknown => "unknown",
        };

        eprintln!(
            "Document {} classified as {} (confidence: {:.2})",
            doc_id, class_name, classification.confidence
        );

        // Call the Go extractor component
        let extracted = extract(&doc_id, class_name, &doc.data)
            .map_err(|e| format!("Extraction failed: {}", e))?;

        if !extracted.errors.is_empty() {
            eprintln!("Extraction warnings for {}: {:?}", doc_id, extracted.errors);
        }

        // Store the results
        store(extracted).map_err(|e| format!("Storage failed: {}", e))?;

        Ok(doc_id)
    }
}

bindings::export!(Orchestrator with_types_in bindings);

The Python classifier:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# classifier/classifier.py
import json
from typing import List, Tuple

# Generated bindings from componentize-py
from classifier_component import exports
from classifier_component.exports.classifier import (
    ClassificationResult, DocumentClass
)

class Classifier(exports.Classifier):
    def classify(self, doc_id: str, content: bytes) -> ClassificationResult:
        """
        In a real implementation, this would call a loaded ML model.
        For demonstration, we use simple heuristics.
        """
        text = content.decode('utf-8', errors='ignore').lower()

        scores = {
            DocumentClass.INVOICE: 0.0,
            DocumentClass.CONTRACT: 0.0,
            DocumentClass.REPORT: 0.0,
            DocumentClass.EMAIL: 0.0,
            DocumentClass.UNKNOWN: 0.1,
        }

        # Simple keyword scoring (a real system would use a model)
        if any(w in text for w in ['invoice', 'total due', 'bill to', 'payment']):
            scores[DocumentClass.INVOICE] += 0.8
        if any(w in text for w in ['agreement', 'whereas', 'hereinafter', 'contract']):
            scores[DocumentClass.CONTRACT] += 0.8
        if any(w in text for w in ['executive summary', 'findings', 'methodology']):
            scores[DocumentClass.REPORT] += 0.8
        if any(w in text for w in ['from:', 'to:', 'subject:', 'dear']):
            scores[DocumentClass.EMAIL] += 0.8

        # Find best class
        best_class = max(scores, key=lambda k: scores[k])
        confidence = scores[best_class]

        labels = [(cls.name.lower(), score) for cls, score in scores.items() if score > 0]
        labels.sort(key=lambda x: x[1], reverse=True)

        return ClassificationResult(
            doc_id=doc_id,
            class_=best_class,
            confidence=confidence,
            labels=labels,
        )

The Go extractor:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
// extractor/main.go
package main

import (
    "encoding/json"
    "strings"

    "go.bytecodealliance.org/cm"
    gen "github.com/myorg/extractor/gen"
    "github.com/myorg/extractor/gen/myorg/docpipeline/extractor"
    "github.com/myorg/extractor/gen/wasi/filesystem/preopens"
)

type ExtractorImpl struct{}

func (e *ExtractorImpl) Extract(docID string, docClass string, content []byte) cm.Result[extractor.ExtractedData, extractor.ExtractedData, string] {
    text := string(content)
    var fields []cm.Tuple[string, string]
    var errors []string

    switch docClass {
    case "invoice":
        fields = extractInvoiceFields(text)
    case "contract":
        fields = extractContractFields(text)
    default:
        errors = append(errors, "no specialized extractor for class: "+docClass)
        fields = extractGenericFields(text)
    }

    return cm.OK[cm.Result[extractor.ExtractedData, extractor.ExtractedData, string]](
        extractor.ExtractedData{
            DocID:  docID,
            Fields: fields,
            Tables: extractTables(text),
            Errors: errors,
        },
    )
}

func (e *ExtractorImpl) Store(data extractor.ExtractedData) cm.Result[struct{}, struct{}, string] {
    // Write to filesystem (preopened dir granted by host)
    dirs := preopens.GetDirectories()
    if dirs.Len() == 0 {
        return cm.Err[cm.Result[struct{}, struct{}, string]]("no preopened directories available")
    }

    jsonData, err := json.Marshal(data)
    if err != nil {
        return cm.Err[cm.Result[struct{}, struct{}, string]](err.Error())
    }

    // Use the first preopened directory
    // (in production, you would use the wasi:filesystem API)
    _ = jsonData  // simplified for demonstration
    return cm.OK[cm.Result[struct{}, struct{}, string]](struct{}{})
}

func extractInvoiceFields(text string) []cm.Tuple[string, string] {
    // Simple field extraction — real implementation uses regex or ML
    var fields []cm.Tuple[string, string]
    lines := strings.Split(text, "\n")
    for _, line := range lines {
        if strings.Contains(line, "Total:") {
            parts := strings.SplitN(line, ":", 2)
            if len(parts) == 2 {
                fields = append(fields, cm.Tuple[string, string]{F0: "total", F1: strings.TrimSpace(parts[1])})
            }
        }
    }
    return fields
}

func extractContractFields(text string) []cm.Tuple[string, string] {
    return extractGenericFields(text)
}

func extractGenericFields(text string) []cm.Tuple[string, string] {
    return []cm.Tuple[string, string]{
        {F0: "word_count", F1: fmt.Sprintf("%d", len(strings.Fields(text)))},
    }
}

func extractTables(_ string) []cm.List[cm.List[string]] {
    return nil
}

func init() {
    extractor.SetExportsMyorgDocpipelineExtractor(&ExtractorImpl{})
}

func main() {}

Compose all three:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Build each component
cd orchestrator && cargo component build --release
cd classifier && componentize-py --wit-path ../wit --world classifier-component componentize classifier -o classifier.wasm
cd extractor && tinygo build -target=wasip2 -o extractor.wasm .

# Compose into a single deployable artifact
wasm-tools compose \
  orchestrator/target/wasm32-wasip1/release/orchestrator.wasm \
  -d classifier/classifier.wasm \
  -d extractor/extractor.wasm \
  -o pipeline.wasm

# The result: a single .wasm that speaks wasi:http on the outside,
# contains three language runtimes (Rust stdlib, Python interpreter embedded in
# the WASM, Go runtime) and wires them together via WIT interfaces.
wasmtime serve --addr 0.0.0.0:8080 pipeline.wasm

Note on Python embedding: componentize-py embeds a minimal Python interpreter inside the WASM component, compiled to WASM32. This makes Python components significantly larger than Rust or Go (typically 5-20MB vs 100KB-2MB), but the isolation and interface benefits remain.


WASI Preview 2 Interfaces

WASI Preview 2 is the collection of standard WIT interfaces maintained by the WASI subgroup of the WebAssembly CG. It shipped as stable in January 2024.

Core Stable Interfaces

wasi:io — The foundational streaming I/O interface. Everything else in WASI that does I/O builds on these types.

// wasi:io/streams
interface streams {
    resource input-stream {
        read: func(len: u64) -> result<list<u8>, stream-error>;
        blocking-read: func(len: u64) -> result<list<u8>, stream-error>;
        subscribe: func() -> pollable;
    }

    resource output-stream {
        write: func(contents: list<u8>) -> result<_, stream-error>;
        blocking-write-and-flush: func(contents: list<u8>) -> result<_, stream-error>;
        flush: func() -> result<_, stream-error>;
        subscribe: func() -> pollable;
    }

    variant stream-error { last-operation-failed(error), closed }
}

// wasi:io/poll
interface poll {
    resource pollable {
        block: func() -> _;
        ready: func() -> bool;
    }
    poll: func(in: list<borrow<pollable>>) -> list<u32>;
}

wasi:clocks — Wall clock and monotonic clock access.

interface wall-clock {
    record datetime {
        seconds: u64,
        nanoseconds: u32,
    }
    now: func() -> datetime;
    resolution: func() -> datetime;
}

interface monotonic-clock {
    type instant = u64;  // nanoseconds
    type duration = u64;
    now: func() -> instant;
    resolution: func() -> duration;
    subscribe-instant: func(when: instant) -> pollable;
    subscribe-duration: func(when: duration) -> pollable;
}

wasi:filesystem — POSIX-like filesystem access, capability-based.

interface types {
    resource descriptor {
        // File operations
        read-via-stream: func(offset: filesize) -> result<input-stream, error-code>;
        write-via-stream: func(offset: filesize) -> result<output-stream, error-code>;
        append-via-stream: func() -> result<output-stream, error-code>;
        stat: func() -> result<descriptor-stat, error-code>;
        set-size: func(size: filesize) -> result<_, error-code>;

        // Directory operations
        open-at: func(
            path-flags: path-flags,
            path: string,
            open-flags: open-flags,
            flags: descriptor-flags,
        ) -> result<descriptor, error-code>;

        read-directory: func() -> result<directory-entry-stream, error-code>;
        unlink-file-at: func(path: string) -> result<_, error-code>;
        rename-at: func(old-path: string, new-descriptor: borrow<descriptor>, new-path: string) -> result<_, error-code>;
    }
}

interface preopens {
    // Returns the pre-opened directory handles granted by the host
    get-directories: func() -> list<tuple<descriptor, string>>;
}

wasi:random — Cryptographically secure random number generation.

interface random {
    get-random-bytes: func(len: u64) -> list<u8>;
    get-random-u64: func() -> u64;
}

interface insecure {
    get-insecure-random-bytes: func(len: u64) -> list<u8>;
    get-insecure-random-u64: func() -> u64;
}

wasi:cli — Standard streams, environment, arguments, and exit.

interface environment {
    get-environment: func() -> list<tuple<string, string>>;
    get-arguments: func() -> list<string>;
    initial-cwd: func() -> option<string>;
}

interface stdin {
    get-stdin: func() -> input-stream;
}

interface stdout {
    get-stdout: func() -> output-stream;
}

interface run {
    run: func() -> result;
}

wasi:sockets — TCP and UDP networking. Landed in Preview 2.

// wasi:sockets/tcp
interface tcp {
    resource tcp-socket {
        start-bind: func(network: borrow<network>, local-address: ip-socket-address) -> result<_, error-code>;
        finish-bind: func() -> result<_, error-code>;
        start-connect: func(network: borrow<network>, remote-address: ip-socket-address) -> result<_, error-code>;
        finish-connect: func() -> result<tuple<input-stream, output-stream>, error-code>;
        start-listen: func() -> result<_, error-code>;
        finish-listen: func() -> result<_, error-code>;
        accept: func() -> result<tuple<tcp-socket, input-stream, output-stream>, error-code>;
        local-address: func() -> result<ip-socket-address, error-code>;
        remote-address: func() -> result<ip-socket-address, error-code>;
        // ... more methods
    }

    create-tcp-socket: func(address-family: ip-address-family) -> result<tcp-socket, error-code>;
}

wasi:http — HTTP client and server. This is what makes Spin and wasmtime serve work.

// wasi:http/types
interface types {
    resource incoming-request {
        method: func() -> method;
        path-with-query: func() -> option<string>;
        scheme: func() -> option<scheme>;
        authority: func() -> option<string>;
        headers: func() -> headers;
        consume: func() -> result<incoming-body>;
    }

    resource outgoing-request {
        constructor(headers: headers);
        body: func() -> result<outgoing-body>;
        method: func() -> method;
        set-method: func(method: method) -> result;
        path-with-query: func() -> option<string>;
        set-path-with-query: func(path-with-query: option<string>) -> result;
        authority: func() -> option<string>;
        set-authority: func(authority: option<string>) -> result;
        scheme: func() -> option<scheme>;
        set-scheme: func(scheme: option<scheme>) -> result;
    }

    resource outgoing-response {
        constructor(headers: headers);
        status-code: func() -> status-code;
        set-status-code: func(status-code: status-code) -> result;
        body: func() -> result<outgoing-body>;
    }

    resource incoming-body {
        stream: func() -> result<input-stream>;
        %finish: static func(this: incoming-body) -> future-trailers;
    }
}

// wasi:http/incoming-handler — what HTTP server components implement
interface incoming-handler {
    handle: func(request: incoming-request, response-out: response-outparam) -> _;
}

// wasi:http/outgoing-handler — what HTTP client components import
interface outgoing-handler {
    handle: func(
        request: outgoing-request,
        options: option<request-options>,
    ) -> result<future-incoming-response, error-code>;
}

Interfaces in Progress (WASI 0.3 / Preview 3)

Several important interfaces are still being finalized:

wasi:messaging — Pub/sub messaging (Kafka, NATS, etc.). The draft exists but runtime support is limited.

wasi:sql — Relational database access. Currently most frameworks use their own proprietary interfaces (Spin has fermyon:spin/sqlite).

wasi:blobstore — Object storage (S3-compatible). Draft exists.

Async/Streams in Preview 3: WASI Preview 3 introduces native async support using the future<T> and stream<T> types in WIT. This is significant — Preview 2 handles async through polling and non-blocking APIs, which is workable but verbose. Preview 3’s async model will enable cleaner async code patterns across all host languages.

// Preview 3 async WIT (draft)
interface async-processor {
    // stream<T> allows incremental production/consumption
    process-stream: func(input: stream<u8>) -> stream<result<u8, string>>;

    // future<T> for single async values
    fetch-data: func(url: string) -> future<result<list<u8>, string>>;
}

Current State and Honest Assessment

What Is Production-Ready

Spin on Fermyon Cloud: Completely production-ready. Fermyon runs a managed cloud platform for Spin applications. It handles HTTP triggers, key-value storage, SQLite, and Redis with high availability. The Spin v3.x SDK is stable. This is the best path for teams who want to ship Component Model applications today without managing infrastructure.

Rust components: First-class support. cargo-component, wit-bindgen, and the Rust WASM target are mature. The tooling is fast and the debugging experience (while not perfect) is reasonable with DWARF support in wasmtime.

Basic component composition: wasm-tools compose works reliably for statically composing components. If you control both components and they share a compatible WIT interface, composition is straightforward.

wasmtime as a library: Embedding wasmtime for plugin systems is production-quality. Companies like Fastly (which runs billions of requests through Wasm) have used wasmtime in production for years. The Component Model embedding API is stable as of wasmtime 18+.

WASI Preview 2 core interfaces: wasi:io, wasi:clocks, wasi:random, wasi:filesystem, wasi:cli are stable and implemented consistently across wasmtime and Spin. wasi:http is stable and the primary interface for server-side components.

SpinKube: Running Spin applications on Kubernetes via SpinKube is production-ready. It is in active use at organizations running Wasm workloads at scale.

What Is Still Maturing

Python components: componentize-py works but produces large binaries (typically 10-30MB because the Python interpreter is embedded in the WASM). Startup is slower than Rust. Import compatibility is incomplete — you cannot use all Python packages, only those with pure-Python or WASM-compatible implementations. This will improve as the Python ecosystem adapts, but it is not seamless today.

Go components: TinyGo support for WASM components is functional but the Go standard library coverage is incomplete. Full go (not TinyGo) support for wasm32-wasip2 is in progress. For simple use cases TinyGo is fine, but complex Go programs may hit missing standard library features.

JavaScript/TypeScript via jco: Workable for server-side use cases. The jco transpilation to Node.js modules works. Building components from JS is reasonable for simple cases. Performance is V8-in-WASM, which is slower than native.

Component debugging: Debugging Wasm components is inferior to native debugging. You can get source-level stack traces in wasmtime with DWARF info, but interactive debugging (breakpoints, variable inspection in a GUI debugger) is limited. The tooling is actively improving — the Bytecode Alliance has invested in debug tooling — but it is not at the level of native development.

Full WASI sockets coverage: wasi:sockets is in Preview 2 and wasmtime supports it, but not all runtimes do. If you need raw TCP/UDP socket access in your components (not just the HTTP interface), verify your target runtime’s support.

The toolchain fragmentation story: You will encounter different versions of WIT world names, different crate versions of wit-bindgen, different WASI feature flags needed. The ecosystem is moving fast and version compatibility requires attention. Pinning exact toolchain versions in your CI is important.

Component Model vs Docker: Where Wasm Wins

For general-purpose server workloads — databases, complex stateful services, applications with extensive library dependencies — containers remain the right tool. Docker’s ecosystem, debugging tools, and operational familiarity are massive advantages.

The Component Model beats Docker for specific categories:

Plugin systems: If you need to run untrusted code from external sources (user-defined logic, third-party extensions, user-uploaded transformations), components are strictly better. Docker containers as plugins are operationally heavy and share the kernel. WASM components are microsecond-startup sandboxes with typed interfaces. There is no comparison.

Edge functions: Latency-sensitive request processing at CDN/edge nodes. Docker containers at the edge are impractical — you cannot cold-start a container in 1ms. WASM components can. Cloudflare Workers and Fastly Compute prove this at internet scale.

Polyglot microservices with tight coupling: If you have services that need to call each other in a tight loop with minimal serialization overhead, component composition is more efficient than gRPC or HTTP between containers. The Canonical ABI overhead for crossing component boundaries is measured in nanoseconds, not milliseconds.

Untrusted code execution platforms: If you are building a platform where customers run their own code (like serverless functions, data transformation pipelines, or customizable business logic), the WASM sandbox with explicit capability grants gives you much stronger isolation guarantees than containers with a much lower overhead. The market for this is growing.

Highly resource-constrained environments: IoT and embedded systems where 10MB containers are too large. A WASM component for a simple HTTP handler can be 200KB.

The Roadmap

The Bytecode Alliance’s near-term priorities:

WASI 0.3 / Preview 3: Native async support with future<T> and stream<T> types. This will significantly improve the ergonomics of writing async components and close the gap with native async programming models.

Component Model Registries: A standard OCI-compatible registry for distributing WIT packages and component binaries. The warg (WebAssembly Registry) protocol is in development.

Threads: wasi-threads is experimental but moving toward standardization. This will allow components to do parallel CPU work within a single component without spawning multiple instances.

Better language tooling: The Go, Python, and JS toolchains are actively improving with each release. The 12-month trajectory for these has been significant — compare componentize-py today to its state 18 months ago.


Getting Started: A Practical Path

If you want to get hands-on with the Component Model today:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 1. Install the toolchain
rustup target add wasm32-wasip1 wasm32-wasip2
cargo install cargo-component wasm-tools wit-bindgen-cli
curl https://wasmtime.dev/install.sh -sSf | bash
curl -fsSL https://developer.fermyon.com/downloads/install.sh | bash

# 2. Create a new Spin component (easiest starting point)
spin new -t http-rust hello-component
cd hello-component
spin build && spin up
curl http://localhost:3000/
# Hello, Fermyon!

# 3. Inspect the component's WIT interfaces
wasm-tools component wit target/wasm32-wasip1/release/hello_component.wasm

# 4. Try a custom WIT interface
mkdir -p wit/deps
cat > wit/my-interface.wit << 'EOF'
package myorg:hello@0.1.0;

interface greeter {
    greet: func(name: string, formal: bool) -> string;
}

world hello-world {
    import wasi:logging/logging@0.1.0;
    export greeter;
    export wasi:http/incoming-handler@0.2.0;
}
EOF

# 5. Validate your WIT
wasm-tools component wit --wat wit/my-interface.wit

# 6. Build and compose
cargo component build --release
wasm-tools validate target/wasm32-wasip1/release/hello_component.wasm

# 7. Look at other people's components
# The Bytecode Alliance publishes example components:
# https://github.com/bytecodealliance/component-docs/tree/main/component-model/examples

The broader resource list:

  • github.com/bytecodealliance/component-docs — official Component Model docs
  • github.com/WebAssembly/component-model — the specification itself
  • github.com/WebAssembly/WASI — WASI interfaces and proposals
  • github.com/bytecodealliance/wit-bindgen — binding generators
  • github.com/bytecodealliance/wasm-tools — toolchain
  • developer.fermyon.com — Spin docs and tutorials
  • docs.wasmtime.dev — wasmtime embedding guide

The Component Model is not yet frictionless. You will hit toolchain version mismatches, incomplete language support, and sparse documentation for edge cases. But the foundation is sound, the specification is stable, and the tooling is improving rapidly. Engineers who understand the Component Model today are building expertise that will be genuinely valuable as WASM moves from emerging to mainstream. The “WebAssembly is only for browsers” era is definitively over.

Comments