The Wasm Component Model on Fastly Compute
The Module Before the Component
October 21, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
The module rustc links for a wasm32-wasip2 Compute service, before the linker turns it into a component, is an ordinary core module. It validates like any other, and nothing in its binary format says it's meant to become a component. What sets it apart is a naming scheme and about 49 KB of custom sections.
The Same Tally, in MoonBit split a build into two stages, a core module and then a component made from it, and What the World Leaves Out found an import that only the second stage added. The module in between deserves a closer look before anything gets built around it.
The first stage, stopped
Full working code: full example on GitHub (opens in a new window).
The example is a service whose handler reads the request's URI and sends it back:
wit_bindgen::generate!({ path: "wit", world: "fastly-compute-guest", generate_all });
use exports::fastly::compute::http_incoming;
use fastly::compute::{http_body, http_resp};
fn respond(text: &str) -> Result<(), ()> {
let response = http_resp::Response::new().map_err(|_| ())?;
let body = http_body::new().map_err(|_| ())?;
http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, body).map_err(|_| ())
}
struct Service;
impl http_incoming::Guest for Service {
fn handle(request: http_incoming::Request, _body: http_body::Body) -> Result<(), ()> {
let uri = request.get_uri(8192).map_err(|_| ())?;
respond(&format!("uri: {uri}\n"))
}
}
export!(Service);
fastly compute serve builds it the usual way, with one linker command. The example's stages.sh builds it a second way, one stage at a time:
cargo build --target-dir target/stage1 --config 'target.wasm32-wasip2.rustflags=["-C", "link-arg=--skip-wit-component"]'
cp target/stage1/wasm32-wasip2/debug/from_core_module_to_component.wasm target/core.wasm
wasm-tools component new target/core.wasm -o target/component.wasm
--skip-wit-component tells wasm-component-ld to stop after wasm-ld, so core.wasm is the first stage's output. Its first eight bytes say so:
0061 736d 0100 0000 # target/core.wasm: a core module
0061 736d 0d00 0100 # target/component.wasm: a component
A core module with a plan
wasm-tools print shows the core module's imports and exports. Here are the ones that aren't wasi:, with type indices and symbol names left out:
(import "fastly:compute/http-resp@0.1.0" "[resource-drop]response" (func (param i32)))
(import "fastly:compute/http-resp@0.1.0" "send-downstream" (func (param i32 i32 i32)))
(import "fastly:compute/http-resp@0.1.0" "[static]response.new" (func (param i32)))
(import "fastly:compute/async-io@0.1.0" "[resource-drop]pollable" (func (param i32)))
(import "fastly:compute/http-req@0.1.0" "[resource-drop]request" (func (param i32)))
(import "fastly:compute/http-req@0.1.0" "[method]request.get-uri" (func (param i32 i64 i32)))
(import "fastly:compute/http-body@0.1.0" "new" (func (param i32)))
(import "fastly:compute/http-body@0.1.0" "write" (func (param i32 i32 i32 i32)))
(export "memory" (memory 0))
(export "fastly:compute/http-incoming@0.1.0#handle" (func ...))
(export "cabi_realloc" (func ...))
The 19 wasi: imports follow this pattern too. Read as names, the list is a WIT world written out longhand:
- Each import's module name is a WIT interface, version included, and the field is the function. A function on a resource gets a bracketed prefix:
[method]request.get-uri,[static]response.new. Dropping a handle is an import too:[resource-drop]request. - Each export is named
interface#function.fastly:compute/http-incoming@0.1.0#handleis thehandlethis series has implemented since Hello, World. memoryandcabi_reallocare exported for the runtime.cabi_reallocis the module's allocator, made callable from outside, and the next section shows why the runtime needs it.
Flattened
Here's get-uri in compute.wit:
/// Gets the request URI.
get-uri: func(max-len: u64) -> result<string, error>;
In the core module, it's this import:
(import "fastly:compute/http-req@0.1.0" "[method]request.get-uri" (func (param i32 i64 i32)))
The first i32 is the request handle for self, and the i64 is max-len. The return type is gone, because a result<string, error> doesn't fit in core Wasm return values. The canonical ABI handles that by giving the function one more parameter, the last i32, a pointer to space in the module's memory where the result gets written. The Rust bindings reserve that space in the calling function's stack frame.
The URI itself needs space too, and only the module knows where its free memory is. So the runtime calls cabi_realloc to get room for the string, copies the URI in, and writes its address and length at the result pointer, after a tag that says the result is ok. That's the copy The Same Tally, in MoonBit described for name, seen from the receiving end.
The other direction has a matching export. When a module exports a function that returns a string or list, it also exports a cabi_post_ function for it, which the runtime calls once it has read the result, so the module can free it. This example's handle returns no data, so it doesn't need one, but the no-files component from What the World Leaves Out has several, such as cabi_post_wasi:filesystem/preopens@0.2.6#get-directories.
Names say which, sections say what
(param i32 i64 i32) could be a hundred different WIT functions. The names say which one this is, but the types live elsewhere, in custom sections:
custom "component-type:wit-bindgen:0.57.1:local:main:fastly-compute-guest:encoded world" 10099 bytes
custom "component-type:wit-bindgen:0.45.0:wasi:http@0.2.4:proxy-with-all-of-its-exports-removed:..." 6921 bytes
custom "component-type:wit-bindgen:0.45.0:wasi:cli@0.2.4:command-with-all-of-its-exports-removed:..." 10794 bytes
custom "component-type:wit-bindgen:0.45.0:wasi:cli@0.2.4:imports:..." 10730 bytes
custom "component-type:wasip2__wasi_libc" 10726 bytes
The first is the example's own world, embedded by wit-bindgen while it compiled. The other four came with the standard library and wasi-libc. These are the sections Reading WIT Back Out of a Component found wit-bindgen writing. MoonBit's moon build writes none, which is why its build runs wasm-tools component embed to add one.
The naming scheme itself isn't a standard yet. wit-parser, the Bytecode Alliance library that both stages use to read WIT, calls it the "legacy" mangling, and its doc comment says it's "the original support for how components were created from core wasm modules and this does not correspond to any standard." That library also supports a newer scheme "specified in WebAssembly/component-model#378," but the toolchains in this series still emit the legacy one.
This is also what Hello World ran into, from the other side. The Rust SDK's fastly_http_body import has the right shape, a module name and a function name, but the module name isn't a WIT interface and no section describes it. The componentizer had nothing to work from, which is why the error said "module requires an import interface named fastly_http_body."
Beyond the WIT
For a binding, this module is where the job's boundaries are. A new language doesn't need a compiler that understands components. It needs to produce a core module that follows the naming scheme, flattens each function the way the canonical ABI says, exports memory, cabi_realloc, and a cabi_post_ for each export that returns data, and carries a component-type section, or lets wasm-tools component embed add one. MoonBit's build in The Same Tally, in MoonBit is that list, followed by component new.
Nothing checks that list while you build. The module validates as ordinary core Wasm whether its names are right or not, and a wrong one surfaces only when the second stage looks for an interface that isn't there, the way the Rust SDK's fastly_http_body did in Hello World. The names are also the part most likely to change. The scheme is the "legacy" one, with a standard proposed to replace it, so a binding that writes these names by hand should expect to update them.
Next: we run the second stage on this module, look at what it builds around it, and find out where the @0.2.6 on every WASI import comes from.