The Wasm Component Model on Fastly Compute

From Core Module to Component

EDIT (2026-10-25): corrected where the @0.2.4 build is refused (at activation, not at the upload).

Early MS-DOS linkers couldn't produce a .COM file (opens in a new window) directly. They produced an .EXE, and a separate utility that shipped with MS-DOS, exe2bin (opens in a new window), converted it to .COM. This was a mechanism I hadn't touched since high school, but I was surprised and fascinated to find that the build process of a Compute service that targets wasm32-wasip2 carries a similar shape. The linker writes the format it has always written, a core module, and a separate step turns it into the component the host runs.

The Module Before the Component stopped a build after its first stage and read what it left behind: imports named after WIT interfaces, flattened signatures, and component-type sections holding the WIT. The second stage reads all three, and it adds more than a wrapper.

What the second stage adds

Full working code: full example on GitHub (opens in a new window).

Diagram of the two stages of a wasm32-wasip2 build, with stage 2 highlighted as the subject of this post and stage 1 dimmed. 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 this post 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

The example's stages.sh builds a service one stage at a time. It stops the linker after wasm-ld with --skip-wit-component, which leaves core.wasm, and then runs the second stage by hand:

Bash
wasm-tools component new target/core.wasm -o target/component.wasm

The first eight bytes of each file show the change:

Terminal output
0061 736d 0100 0000   # target/core.wasm: a core module
0061 736d 0d00 0100   # target/component.wasm: a component

Both start with 0061 736d, the \0asm magic number. Core Wasm reads the next four bytes as a single version number, 1. The Component Model reads them as two fields, a version and a layer:

File Bytes Version Layer
core.wasm 0100 0000 1 0: a core module
component.wasm 0d00 0100 0x0d 1: a component

A core module's version 1 already reads as layer 0, so the layer can tell the two apart without changing any existing file. The 0x0d is a pre-standard version, which the Component Model's binary spec (opens in a new window) says will change "one last time to 0x1" once the standard is final.

Diagram of what wasm-tools component new builds. On the left, core.wasm: 27 imports named after WIT interfaces, 3 exports, and component-type sections holding the WIT. An arrow labeled component new leads to component.wasm on the right, which is built in layers from top to bottom. First, imported instances, typed, one per interface. From them, canon lower, one per imported function, and beside it canon resource.drop, nine of them, provided by the runtime. Both feed the core module, which goes in unchanged, with the same names, code, and data. From the core module's export, canon lift, one per export, here handle. From that, the exported instance, http-incoming, which the host calls. core.wasm 27 imports named after WIT interfaces 3 exports component-type sections holding the WIT component new component.wasm imported instances typed, one per interface canon lower one per imported function canon resource.drop 9, provided by the runtime core module unchanged: names, code, data canon lift one per export: handle exported instance http-incoming, called by the host arrows follow each function as it's handed along: imports in, exports out

wasm-tools component new reads the names and the sections and builds a component around the module:

  1. The module goes in unchanged. The component holds it as an embedded core module, with its 27 imports and 3 exports under their original names and its code, data, and debug info untouched. Only the component-type sections are gone. They've been turned into real declarations, which is part of why component.wasm is smaller than core.wasm.

  2. Imports get real types. The component declares each interface as a typed instance. Here's its http-req, cut down to the one function the module calls:

    wat
    (type $ty-fastly:compute/http-req@0.1.0
      (instance
        (export "request" (type (sub resource)))
        (alias outer 1 $"#type8 error" (type))
        (export "error" (type (eq 1)))
        (type (borrow 0))
        (type (result string (error 2)))
        (type (func (param "self" 3) (param "max-len" u64) (result 4)))
        (export "[method]request.get-uri" (func (type 5)))))

    Inside the instance, types are referred to by number: 0 is request, 3 is a borrow of it, and 4 is the result, with error pulled in from outside the instance by alias outer. That's get-uri, which The Module Before the Component found flattened to (param i32 i64 i32) in the core module, with its parameter names and its result<string, error> back.

  3. canon lower turns each typed import into the flat function the module expects. canon is a keyword in the text that wasm-tools print shows for a component. It marks a function that no module contains, one the canonical ABI builds from another function. Lower is the canonical ABI's word for what The Same Tally, in MoonBit saw happen to name: a value from outside put into the module's memory. Each canon lower also records how to do it:

    wat
    (canon lower (func $"[method]request.get-uri") (memory $memory) (realloc $cabi_realloc) string-encoding=utf8)

    It names the module's memory, its cabi_realloc, and the string-encoding option, which wit-bindgen set to utf8 for Rust (The Same Tally, in MoonBit covered it).

  4. canon lift does the reverse for each export. Lift is the other direction from The Same Tally, in MoonBit, a value taken out of a module's memory and handed to whoever called. canon lift turns the module's flat handle into the typed handle the host calls:

    wat
    (canon lift (core func $fastly:compute/http-incoming@0.1.0#handle))

    This one names no memory and no cabi_realloc. handle takes two handles and returns a result with no data in it, so nothing it passes has to go through the module's memory.

  5. Resource drops become built-ins. The [resource-drop] imports aren't implemented by any interface. Each one becomes a canon resource.drop, which the runtime provides, and this component has nine. Here's the one for request:

    wat
    (canon resource.drop $"#type35 request")

    It names only the resource type, so dropping a request handle needs nothing from the module and nothing from http-req.

A table to break a cycle

That canon lower names $memory and $cabi_realloc because the runtime needs both when get-uri returns: it calls that cabi_realloc for space and writes the URI into that memory. They're fixed when the lowered function is made.

$memory and $cabi_realloc are the component's labels for two of the module's exports, and here's where it defines them:

wat
(alias core export $main "memory" (core memory $memory))
(alias core export $main "cabi_realloc" (core func $cabi_realloc))

Each line takes an export from $main, the main module's instance, so neither can come before the module is instantiated.

But instantiating the module means supplying all of its imports, including the lowered get-uri that needs them. Each one is waiting for the other.

Diagram of the cycle between the main module and its lowered imports. Instantiating the main module needs all 27 of its imports, so it needs the lowered get-uri as an import. The lowered get-uri is made by canon lower, which names the module's memory and cabi_realloc, so it needs both to exist. Memory and cabi_realloc are exports of the main module, so they exist only after it is instantiated, which closes the loop. Below, outside the cycle: the other 6 lowered imports name no memory, so they are made first. instantiate main module needs all 27 imports first needs it as an import lowered get-uri canon lower names memory needs both to exist memory, cabi_realloc exports of the main module exist only after it the cycle the other 6 lowered imports no memory named, so they're made first

The 12 imports whose canon lower names the memory are caught in this loop. The other 6, such as pollable.block and exit, pass nothing through memory, so they're lowered before the module exists and handed to it directly.

The componentizer breaks the cycle with two tiny modules:

  1. wit-component-shim-module instantiates first. It has a table with an entry for each of those 12 imports, and exports a stand-in for each one. The stand-in for get-uri takes get-uri's flat parameters and forwards them with call_indirect, the core Wasm instruction that calls whatever function is in a given slot of a table, here slot 2:

    wat
    (func $"indirect-fastly:compute/http-req@0.1.0-[method]request.get-uri" (param i32 i64 i32)
      local.get 0
      local.get 1
      local.get 2
      i32.const 2
      call_indirect (type 2))
  2. The main module instantiates against the stand-ins. Its imports are satisfied, and now memory and cabi_realloc exist.

  3. The real lowered functions are created with canon lower, using those exports.

  4. wit-component-fixup instantiates last. It imports the shim's table and the 12 lowered functions, and its only content is an elem segment:

    wat
    (elem (i32.const 0) func $0 $1 $2 $3 $4 $5 $6 $7 $8 $9 $10 $11)

    An element segment is the table's counterpart to a data segment. Where a data segment copies bytes into memory when a module is instantiated, an element segment puts function references into a table. This one starts at slot 0, (i32.const 0), and fills it and the next 11 with $0 through $11, the fixup's 12 imports, which are the lowered functions.

From then on, a call to get-uri goes through the stand-in and the table to the real lowering. The cost is one call_indirect per call, in exchange for never having to change the module.

Diagram of a call to get-uri going through the shim's table, with badges numbered for the four steps in the list above it: 1 on the shim's stand-in and table, 2 on the main module and its exports, 3 on the lowered get-uri, and 4 on the fixup module. On the left, the main module's handle function calls its get-uri import. That import is the shim's stand-in for get-uri, which pushes 2 and does a call_indirect into the shim's table. The table has 12 slots, empty until the fixup module fills them: slot 0 send-downstream, 1 response.new, 2 request.get-uri, 3 http-body.new, 4 http-body.write, 5 check-write, 6 write, 7 blocking-flush, 8 get-environment, 9 get-terminal-stdin, 10 get-terminal-stdout, and 11 get-terminal-stderr. Slot 2 holds the lowered get-uri, made by canon lower after the main module exists, because it names the main module's exports memory and cabi_realloc. The fixup module's elem segment writes the lowered functions into slots 0 through 11. main module shim made afterward handle calls its get-uri import calls stand-in for get-uri i32.const 2, call_indirect table, 12 slots empty until fixup fills it slot holds the lowered 0 send-downstream 1 response.new 2 request.get-uri 3 http-body.new 4 http-body.write 5 check-write 6 write 7 blocking-flush 8 get-environment 9 get-terminal-stdin 10 get-terminal-stdout 11 get-terminal-stderr slot 2 fixup elem segment writes into slots 0 through 11 holds lowered get-uri made by canon lower exports memory, cabi_realloc named when it's made 1 1 2 2 3 4 the module calls the stand-in, and the stand-in calls whatever fixup put in slot 2

In the file, and at load time

None of the cycle-breaking happens while you build. component.wasm holds a plan, and the runtime carries it out each time it instantiates the component:

Piece In the .wasm When the component is instantiated
The shim's table Declared as 12 funcref slots Allocated, with every slot empty
The stand-ins Function bodies ending in call_indirect Ready to call, pointing at empty slots
The lowered functions A canon lower for each import, naming $memory and $cabi_realloc Created, tied to this instance's memory and cabi_realloc
The fixup's elem segment "Imports 0 through 11 go in slots 0 through 11" Applied: the lowered functions are written into the table
The order Declarations: what each module is instantiated with, and the aliases The runtime instantiates the shim, the main module, and the fixup, in that order

The file can't hold a filled table, because what fills it is tied to memory that only exists once the module is instantiated. So the table is rebuilt for every instance. On Compute that isn't necessarily every request, since an instance can be reused, but it is every time a new one starts.

Where @0.2.6 comes from

The core module imports WASI interfaces at @0.2.0, but the components this series deploys import them at @0.2.6, the version Compute's service world requires. The second stage chose that version. When an interface shows up at several versions, the componentizer merges them and keeps the newest. In the linker's build, three things name a version:

Where Version
The core module's imports 0.2.0
The standard library's component-type sections 0.2.4
The wasip1 adapter from What the World Leaves Out, which the linker attaches 0.2.6

The adapter in Rust 1.94's linker targets 0.2.6, so that's what the merge settles on. stages.sh runs wasm-tools component new without an adapter, and its component comes out at @0.2.4. Viceroy runs it. A real Compute service accepts the upload, then refuses to activate it:

Terminal output
Linker typecheck succeeded but WIT typecheck failed: type-checking component with world `fastly:compute/service`, precise=true, library=false
  ...
  missing import named `wasi:io/poll@0.2.4`

That's the "missing import" Hello World described for a component built by a different Rust, reached here with the pinned one. The linker's build of this source deploys and answers uri: ... with the request's URL.

Beyond the WIT

The Component Model was designed for this split. Its first stated goal (opens in a new window) describes "components built from WebAssembly core modules," and a component holds its core module the way this one does, as an embedded part. When LLVM made wasm-component-ld the default linker for wasm32-wasip2, Alex Crichton, who wrote wasm-component-ld, described the two steps in the change's review (opens in a new window): it runs wasm-ld, "producing the core wasm that's expected today," and then the componentization process. "Going from core module + metadata into a component is a pretty nontrivial operation," he wrote. The compiler's job stays what it was, and one shared tool does the component part for every language.

For a binding, a shared second stage also means some decisions get made for you. The WASI versions in the finished component are decided by whatever runs that stage, not by the language or its source, and this post's two builds of one source came out at @0.2.6 and @0.2.4. A binding that runs the second stage itself should check the import list it produced against the service world, version numbers included, before anything gets uploaded.

The shim and fixup modules are the one part of the wrapping that is core Wasm, not declarations. The componentizer writes them into every component, whatever language it came from, and every call to an import that needs the module's memory pays one call_indirect to go through them. They're why a module's imports can depend on its own exports.

Next: a tally written in JavaScript. It has no file code in it at all, and it imports the filesystem anyway.