The Wasm Component Model on Fastly Compute

The Module Before the Component

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:

Rust

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:

Bash
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:

Terminal output
0061 736d 0100 0000   # target/core.wasm: a core module
0061 736d 0d00 0100   # target/component.wasm: a component
Diagram of the two stages of a wasm32-wasip2 build. The Rust source, src/lib.rs, goes through stage 1, where rustc compiles it and wasm-ld links it, and becomes target/core.wasm. That is a core module: its first bytes are 0061 736d 0100 0000, its imports are named after WIT interfaces, and component-type custom sections hold the WIT. --skip-wit-component stops the build here. Stage 2, wit-component, which stages.sh runs as wasm-tools component new, turns it into target/component.wasm. That is a component: its first bytes are 0061 736d 0d00 0100, its imports have real types along with canon lift and lower, and the core module is embedded inside it. The usual build runs both stages in one wasm-component-ld command. src/lib.rs rust source stage 1 rustc compiles, wasm-ld links target/core.wasm 0061 736d 0100 0000: a core module imports named after WIT interfaces component-type sections hold the WIT --skip-wit-component stops the build here stage 2 wit-component makes the component (wasm-tools component new) target/component.wasm 0061 736d 0d00 0100: a component typed imports, canon lift and lower the core module, embedded inside the usual build runs both stages in one wasm-component-ld command

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:

wat
(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:

  1. 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.
  2. Each export is named interface#function. fastly:compute/http-incoming@0.1.0#handle is the handle this series has implemented since Hello, World.
  3. memory and cabi_realloc are exported for the runtime. cabi_realloc is 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:

WIT
/// Gets the request URI.
get-uri: func(max-len: u64) -> result<string, error>;

In the core module, it's this import:

wat
(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.

Diagram of the core-module call to get-uri, whose signature is param i32 i64 i32. The first i32 is self, the request handle. The i64 is max-len, 8192 in the example. The last i32 is a return pointer. It points to a result area that the module reserves on its stack, in its own linear memory. The runtime writes the result there before the call returns. [method]request.get-uri (param i32 i64 i32) self: i32 the request handle max-len: i64 8192 return pointer: i32 where the result goes the module's linear memory result area on the module's stack points to the module reserves the space and passes its address; the runtime writes the result there

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.

Diagram of how the runtime returns the URI. Step 1: the runtime calls the module's cabi_realloc with 0, 0, 1, and the string's length, and gets back the address of new space in the module's memory. Step 2: it copies the URI's bytes, such as http://127.0.0.1:7676/some/path, into that space. Step 3: it writes into the result area at the return pointer a tag at offset 0, where 0 means ok, the address of the bytes at offset 8, and their length at offset 12. The address in the result area points to the copied bytes. runtime returning get-uri's result 1. cabi_realloc(0, 0, 1, len) 2. copy the URI in 3. write tag, address, length result area, at the return pointer tag: 0 (ok) offset 0 address offset 8 length offset 12 space from cabi_realloc the URI's bytes http://127.0.0.1:7676/some/path points to all of it lands in the module's memory, where the bindings read it after the call returns

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:

Terminal output
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.