The Wasm Component Model on Fastly Compute
Hello, World: Exporting http-incoming by Hand
August 3, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): added an aside on what the Rust SDK actually builds, and why it isn't a component, since later posts in this series compare against it.
Recently I've been playing with the Wasm Component Model (opens in a new window) on Fastly Compute (opens in a new window), specifically seeing how far I can get without reaching for any of the Compute language SDK (opens in a new window)s.
I'm doing this in Rust (opens in a new window) (the language, not the SDK), and not just because it's the reference language for tooling like wit-bindgen and cargo component. All you need to do is spend a little time learning the WIT (Wasm Interface Type) (opens in a new window) language, and it becomes obvious how much it borrows (no pun intended) from Rust conceptually. borrow<T> shows up throughout compute.wit itself (async-io.select, for instance, takes list<borrow<pollable>>), and it means exactly what it means in Rust: a temporary, non-owning handle to a resource someone else owns. If Rust's ownership and borrowing rules already live in your head, WIT's resource-handle semantics aren't a new thing to learn—they're a vocabulary you already have.
If you've written a "hello world" on Fastly Compute before, if it was in Rust, it probably looked like this: #[fastly::main], a function that takes a Request and returns a Response, maybe a println! for good measure. One attribute macro, and the SDK quietly wires your function up to whatever the platform actually expects.
That wiring is the part we're going to do by hand. The gap between that macro and the ABI underneath is wider than it looks, but seeing why takes a short detour through WASI first.
WASI Preview 2
WebAssembly (opens in a new window) (Wasm for short)—being a sandboxed virtual machine format (with its own bytecode)—can only do pure computation. To talk to the outside world, a Wasm program can only call functions provided by the host platform. WASI (WebAssembly System Interface) (opens in a new window) is a standardized set of APIs for this purpose, giving Wasm modules access to system resources (filesystem, clock, random, sockets, etc.) in a portable, capability-based way. So it's like a "POSIX for Wasm," but designed with security/sandboxing from the ground up.
WASI itself has grown as a platform since its first release. WASI Preview 2 (p2) is the "current" version, built on top of the WebAssembly Component Model (opens in a new window): interfaces are defined in WIT (Wasm Interface Type) files which describe rich types (strings, records, variants, resources/handles) and get compiled into strongly-typed bindings.
What the SDK is hiding
In the Component Model, there's no main function at the ABI (application binary interface) (opens in a new window) level. For Fastly Compute, compute.wit (opens in a new window) defines an interface your component is expected to export instead:
interface http-incoming {
use http-body.{body};
use http-req.{request};
/// Handle the given request.
///
/// This function is called once per sandbox. When it returns, the
/// sandbox exits. To opt into receiving multiple requests in a single
/// sandbox, use `http-downstream.next-request`.
///
/// To send a response for the given `request`, use `send-downstream`, or to
/// stream the response body after the response has been initiated, use
/// `send-downstream-streaming`.
handle: func(request: request, body: body) -> result;
}
That's the entire contract. Your compiled component exports a function called handle, and the runtime calls it once per sandbox. If your component doesn't export exactly this, there will be a link error when you upload your module to your Fastly service. You export the interface, or you don't run.
So here's the promised answer about that #[fastly::main] macro: it isn't generating any of this. The SDK version isn't a component at all.
That's worth carrying into the rest of this series. Later posts compare what we build by hand against the real Rust SDK, and those are cross-ABI comparisons: the same platform capabilities, reached two different ways.
For this first pass, we're going to ignore both parameters of the handle function entirely and just prove we can get this function called at all.
Setting up
-
Add the WASI Preview 2 target, since components are what we're building:
rustup target add wasm32-wasip2 -
Install the Fastly CLI (opens in a new window), which bundles Viceroy (opens in a new window) (Fastly's local testing server) for serving components locally:
npm install -g @fastly/cli
That's it for tools we need to install.
In addition, we'll be using a tool called wit-bindgen (opens in a new window) as a Rust dependency: this is the library that turns a WIT file (more accurately, a WIT "world" (opens in a new window)) into Rust types and trait bindings at compile time. wit-bindgen is just a regular crate: it shows up as a normal [dependencies] entry in Cargo.toml, and cargo build fetches and runs it like anything else on crates.io (opens in a new window).
The project
Cargo.toml needs the wit-bindgen dependency:
[package]
name = "hello-world-http-incoming"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
wit-bindgen = "0.57.1"
For reasons I'll get into later, we need to use a slightly older version of the Rust toolchain. We specify it in rust-toolchain.toml:
[toolchain]
channel = "1.94.0"
targets = ["wasm32-wasip2"]
Our own world, in wit/world.wit, says one thing:
package local:main;
world hello-world {
export fastly:compute/http-incoming@0.1.0;
}
For this program we're not importing anything ourselves—just exporting the one interface Fastly requires. wit-bindgen needs the actual compute.wit text to resolve that reference, so a copy lives locally under wit/deps/fastly-compute/, alongside the handful of WASI interface packages compute.wit itself depends on (wasi:cli, wasi:clocks, wasi:io, and, somewhat surprisingly, wasi:filesystem and wasi:sockets, neither of which Fastly Compute actually supports; more on that below).
src/bindings.rs is the entire integration point with wit-bindgen:
wit_bindgen::generate!({
path: "wit",
world: "hello-world",
generate_all,
});
wit_bindgen::generate! generates bindings from the specified world hello-world from WIT files under the specified path wit.
generate_all is what makes this a one-liner: without it, wit-bindgen only generates full bindings for interfaces you explicitly list, and expects you to map everything else to existing Rust types with a with clause. hello-world only exports http-incoming, but that interface's own use statements pull in http-body and http-req, and generate_all tells wit-bindgen to generate bindings for those too, transitively, instead of making us enumerate them by hand.
And src/lib.rs is the whole application, "by hand":
mod bindings;
use bindings::{exports::fastly::compute::http_incoming, fastly::compute::http_body};
struct HelloWorld;1
impl http_incoming::Guest for HelloWorld {
// The entry point, defined by exporting `http-incoming:handle`.
fn handle(_request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {2
println!("Hello, world!");
Ok(())
}
}
bindings::export!(HelloWorld with_types_in bindings);3
HelloWorld is a struct name I chose for this program. I chose the name to match world hello-world for readability. There is no actual requirement for the names to match; the only requirement is to call bindings::export! with some type that implements the required Guest trait.
wit_bindgen::generate! in src/bindings.rs turns the http-incoming interface into a Rust trait http_incoming::Guest with one method per WIT function (::Guest is a wit-bindgen convention for exports to be implemented by the Guest). Implementing this trait for HelloWorld is what supplies the function body.
The export! macro is what actually fills the export slot: it emits the component-model plumbing so the compiled binary satisfies the export fastly:compute/http-incoming@0.1.0; line in wit/world.wit. with_types_in bindings just tells export! where to find the types generate! produced, since the two macros are invoked from different files here.
Inside handle itself, we take the request and body, name them _request and _request_body to tell Rust we're deliberately ignoring them, and log a line. No response is sent. We'll get to that in the next post.
Running it
Full working code: full example on GitHub (opens in a new window).
Wire the build into fastly.toml. The language = "other" line is the one carrying weight: it tells the CLI we're not a Rust SDK project, which keeps its built-in toolchain path, and the hard-coded wasm32-wasip1 that comes with it, out of the picture entirely. What's left is our build, target included.
language = "other"
[scripts]
build = """\
cargo build --target wasm32-wasip2 && \
mkdir -p bin && \
cp target/wasm32-wasip2/debug/hello_world_http_incoming.wasm bin/main.wasm\
"""
Then:
fastly compute serve
And from another terminal:
curl http://127.0.0.1:7676/ (opens in a new window)
The HTTP response comes back empty, because we never did anything that would send a response downstream. But look at the terminal running fastly compute serve:
INFO request{id=0}: handling request GET http://127.0.0.1:7676/ (opens in a new window)
Hello, world!
INFO request{id=0}: response status: 200
The text Hello, world! went out through plain Rust println!, which resolves to wasi:cli/stdout at the ABI level: the same standard WASI interface any Wasm Preview 2 component would use. No Fastly-specific interfaces were involved; Viceroy just implements the wasi:cli/stdout interface to send content to the terminal log.
Beyond the WIT
Here's the part that surprised me while putting this together: wit-bindgen doesn't just resolve the parts of compute.wit you actually touch. It parses the entire package—every interface, every world—because WIT files can cross-reference each other in ways the resolver has to validate up front, regardless of whether your own world uses them.
That's why wasi:filesystem and wasi:sockets show up in this project's wit/deps at all, even though nothing in our handle function goes near a file or a socket, and even though Fastly Compute doesn't implement either. They're there because the real, official wasi:cli package bundles whole worlds (command, imports) that reference filesystem and socket interfaces internally—and wit-bindgen has to resolve those references to parse the file, full stop. Fastly's compute.wit only imports specific interfaces out of wasi:cli (environment, stdout, and so on), never those bundled worlds—but the text still has to type-check as a whole.
Next: we stop ignoring the request, and start peeking at what's actually inside it, with hopes of returning some sort of response to the client as well.