The Wasm Component Model on Fastly Compute
From Core Module to Component
October 22, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
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).
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:
wasm-tools component new target/core.wasm -o target/component.wasm
The first eight bytes of each file show the change:
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.
wasm-tools component new reads the names and the sections and builds a component around the module:
-
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 thecomponent-typesections are gone. They've been turned into real declarations, which is part of whycomponent.wasmis smaller thancore.wasm. -
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:(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:
0isrequest,3is aborrowof it, and4is theresult, witherrorpulled in from outside the instance byalias outer. That'sget-uri, which The Module Before the Component found flattened to(param i32 i64 i32)in the core module, with its parameter names and itsresult<string, error>back. -
canon lowerturns each typed import into the flat function the module expects.canonis a keyword in the text thatwasm-tools printshows 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 toname: a value from outside put into the module's memory. Eachcanon loweralso records how to do it:(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 thestring-encodingoption, whichwit-bindgenset toutf8for Rust (The Same Tally, in MoonBit covered it). -
canon liftdoes 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 liftturns the module's flathandleinto the typedhandlethe host calls:(canon lift (core func $fastly:compute/http-incoming@0.1.0#handle))This one names no memory and no
cabi_realloc.handletakes two handles and returns aresultwith no data in it, so nothing it passes has to go through the module's memory. -
Resource drops become built-ins. The
[resource-drop]imports aren't implemented by any interface. Each one becomes acanon resource.drop, which the runtime provides, and this component has nine. Here's the one forrequest:(canon resource.drop $"#type35 request")It names only the resource type, so dropping a
requesthandle needs nothing from the module and nothing fromhttp-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:
(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.
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:
-
wit-component-shim-moduleinstantiates 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 forget-uritakesget-uri's flat parameters and forwards them withcall_indirect, the core Wasm instruction that calls whatever function is in a given slot of a table, here slot 2:(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)) -
The main module instantiates against the stand-ins. Its imports are satisfied, and now
memoryandcabi_reallocexist. -
The real lowered functions are created with
canon lower, using those exports. -
wit-component-fixupinstantiates last. It imports the shim's table and the 12 lowered functions, and its only content is anelemsegment:(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$0through$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.
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:
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.