The Wasm Component Model on Fastly Compute

What the World Leaves Out

EDIT (2026-10-20): redrew the adapter diagram to show the host and the library under your code, and added where the adapter sits, in "The linker's import."

std::fs::read_to_string compiles for wasm32-wasip2 without a single warning. If you put one call to it in a Compute service, even in a branch that never runs, the service never starts.

The service World ran a component that imported something the world doesn't have, wasi:http, and that one did it on purpose. Resources Aren't Host-Only composed two components so that an import the host doesn't offer was satisfied inside. If you put the two together, you can fix an import nobody asked for.

One call is enough

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

The example's service crate is the handler from The service World, with two calls behind Cargo features. fs reads a file with std::fs::read_to_string, and net opens a TCP connection with std::net::TcpStream::connect:

Rust

wit_bindgen::generate!({ path: "../wit", world: "service", 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 mut report = String::new();

        // Imports wasi:filesystem.
        #[cfg(feature = "fs")]
        report.push_str(&format!("fs: {:?}\n", std::fs::read_to_string("/etc/hostname")));

        // Imports wasi:sockets, and wasi:filesystem along with it.
        #[cfg(feature = "net")]
        report.push_str(&format!(
            "net: {:?}\n",
            std::net::TcpStream::connect("192.0.2.1:80").map(|_| ())
        ));

        report.push_str("handled\n");
        respond(&report)
    }
}


export!(Service);

Without either feature, wasm-tools component wit lists the same eighteen imports as The service World did. Building with --features fs adds three:

Terminal output
import wasi:clocks/wall-clock@0.2.6;
import wasi:filesystem/types@0.2.6;
import wasi:filesystem/preopens@0.2.6;

Building with --features net adds eleven: the three from fs, wasi:clocks/monotonic-clock, and seven wasi:sockets interfaces.

Viceroy won't start either build:

Terminal output
ERROR component imports instance `wasi:filesystem/types@0.2.6`, but a matching implementation was not found in the linker

It's the failure the proxy-world crate hit, at a different interface. The handler never runs, because instantiating a component means supplying every one of its imports first. A real Compute service agrees. Publishing the fs build as a new version of a working service uploads the package, then fails to activate it:

Terminal output
ERROR: the Fastly API returned 400 Bad Request: Bad request (ERROR: xqd-codegen failed: exit status: 1

Caused by:
    ComponentTypecheckingError(component imports instance `wasi:filesystem/types@0.2.6`, but a matching implementation was not found in the linker

    Caused by:
        0: instance export `descriptor` has the wrong type
        1: resource implementation is missing)).

The net build fails the same way as the fs build, at wasi:filesystem/types.

So the unit of failure is the component, not the code path. Nothing in the fs build reads a file before the check fails, and nothing would need to. A file read in an error handler that never runs is enough, as long as the build keeps it.

A filesystem with no files in it

Resources Aren't Host-Only fixed a missing import by building a second component that exports it and plugging the two together with wac. Nothing about that requires the interface to be one you wrote. wasi:filesystem is an interface like any other, and a component can export it.

The example's wit/world.wit declares a world for that component next to the service's:

WIT
/// A component that supplies `wasi:filesystem` with no files in it.
world no-files {
  export wasi:filesystem/types@0.2.6;
  export wasi:filesystem/preopens@0.2.6;
}

preopens is how a WASI program finds out which directories it may touch. Its one function, get-directories, returns a list of directory descriptors with a path for each, and the standard library resolves every path you open against that list. The no-files crate returns an empty one:

Rust

wit_bindgen::generate!({ path: "../wit", world: "no-files", generate_all });

use exports::wasi::filesystem::{preopens, types};
use types::{
    Advice, DescriptorBorrow, DescriptorFlags, DescriptorStat, DescriptorType, DirectoryEntry,
    ErrorCode, MetadataHashValue, NewTimestamp, OpenFlags, PathFlags,
};
use wasi::io::streams::{Error, InputStream, OutputStream};

struct NoFiles;

/// No preopened directories, so no path ever resolves.
impl preopens::Guest for NoFiles {
    fn get_directories() -> Vec<(types::Descriptor, String)> {
        Vec::new()
    }
}

/// A descriptor that can't exist: an enum with no variants has no values.
enum NoDescriptor {}

/// A directory listing that can't exist either.
enum NoEntries {}

impl types::Guest for NoFiles {
    type Descriptor = NoDescriptor;
    type DirectoryEntryStream = NoEntries;

    fn filesystem_error_code(_err: &Error) -> Option<ErrorCode> {
        None
    }
}

impl types::GuestDescriptor for NoDescriptor {
    fn read_via_stream(&self, _offset: u64) -> Result<InputStream, ErrorCode> {
        match *self {}
    }

    fn write_via_stream(&self, _offset: u64) -> Result<OutputStream, ErrorCode> {
        match *self {}
    }
    fn append_via_stream(&self) -> Result<OutputStream, ErrorCode> {
        match *self {}
    }
    fn advise(&self, _offset: u64, _length: u64, _advice: Advice) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn sync_data(&self) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn get_flags(&self) -> Result<DescriptorFlags, ErrorCode> {
        match *self {}
    }
    fn get_type(&self) -> Result<DescriptorType, ErrorCode> {
        match *self {}
    }
    fn set_size(&self, _size: u64) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn set_times(&self, _atim: NewTimestamp, _mtim: NewTimestamp) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn read(&self, _length: u64, _offset: u64) -> Result<(Vec<u8>, bool), ErrorCode> {
        match *self {}
    }
    fn write(&self, _buffer: Vec<u8>, _offset: u64) -> Result<u64, ErrorCode> {
        match *self {}
    }
    fn read_directory(&self) -> Result<types::DirectoryEntryStream, ErrorCode> {
        match *self {}
    }
    fn sync(&self) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn create_directory_at(&self, _path: String) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn stat(&self) -> Result<DescriptorStat, ErrorCode> {
        match *self {}
    }
    fn stat_at(&self, _path_flags: PathFlags, _path: String) -> Result<DescriptorStat, ErrorCode> {
        match *self {}
    }
    fn set_times_at(
        &self,
        _path_flags: PathFlags,
        _path: String,
        _atim: NewTimestamp,
        _mtim: NewTimestamp,
    ) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn link_at(
        &self,
        _old_path_flags: PathFlags,
        _old_path: String,
        _new_descriptor: DescriptorBorrow<'_>,
        _new_path: String,
    ) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn open_at(
        &self,
        _path_flags: PathFlags,
        _path: String,
        _open_flags: OpenFlags,
        _flags: DescriptorFlags,
    ) -> Result<types::Descriptor, ErrorCode> {
        match *self {}
    }
    fn readlink_at(&self, _path: String) -> Result<String, ErrorCode> {
        match *self {}
    }
    fn remove_directory_at(&self, _path: String) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn rename_at(
        &self,
        _old_path: String,
        _new_descriptor: DescriptorBorrow<'_>,
        _new_path: String,
    ) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn symlink_at(&self, _old_path: String, _new_path: String) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn unlink_file_at(&self, _path: String) -> Result<(), ErrorCode> {
        match *self {}
    }
    fn is_same_object(&self, _other: DescriptorBorrow<'_>) -> bool {
        match *self {}
    }
    fn metadata_hash(&self) -> Result<MetadataHashValue, ErrorCode> {
        match *self {}
    }
    fn metadata_hash_at(
        &self,
        _path_flags: PathFlags,
        _path: String,
    ) -> Result<MetadataHashValue, ErrorCode> {
        match *self {}
    }
}


impl types::GuestDirectoryEntryStream for NoEntries {
    fn read_directory_entry(&self) -> Result<Option<DirectoryEntry>, ErrorCode> {
        match *self {}
    }
}

export!(NoFiles);

wasi:filesystem/types has 27 methods on its descriptor resource, and a component that exports the interface has to implement every one. This one gets away with an empty match in each. NoDescriptor is an enum with no variants, so no value of it can ever exist, and Rust accepts match *self {} as a complete body for a method that can never be called. The only way to get a descriptor from this filesystem is get-directories, and it never returns one.

The linker's import

If you build no-files for wasm32-wasip2, like everything else in this series, wasm-tools component wit shows a surprise among its imports:

Terminal output
import wasi:filesystem/types@0.2.6;

The component that provides wasi:filesystem imports it too, and none of its code asked for that. The core module rustc compiles doesn't import a single wasi:filesystem function. The import appears in the second of the two stages The Same Tally, in MoonBit laid out, when wasm-component-ld turns the core module into a component.

Along the way, the linker attaches a WASI adapter. Before the Component Model, a WASI program imported plain functions like fd_write and path_open from a core-module namespace called wasi_snapshot_preview1, the wasip1 ABI. The adapter is a small core module that exports those functions and implements them with WASI 0.2 calls. fd_write to standard output becomes a blocking-write-and-flush on the stream from get-stdout, and path_open becomes an open-at on one of the directories from get-directories. If the main module calls any of them, the adapter goes inside the component next to it, and the adapter's imports join the main module's own on the component's import list:

Diagram of where the preview1 adapter sits. Inside the component, your code calls into libc or the language's standard library, such as wasi-libc or Rust's std. In a wasip2 build, that library imports WASI 0.2 interfaces from the host directly. In a wasip1 build, it imports functions such as fd_write and path_open from wasi_snapshot_preview1 instead. The preview1 adapter, also inside the component, exports those functions and implements them with WASI 0.2 calls, which it imports from the host. The host, Compute or Viceroy, is outside the component and sees only WASI 0.2 imports. The wasip1 calls never leave the component. inside the component outside your code libc / std wasi-libc, Rust's std, … wasi_snapshot_preview1 (wasip1 builds) preview1 adapter exports fd_write, path_open, … implements them with WASI 0.2 host Compute or Viceroy WASI 0.2 imports (wasip2 builds) WASI 0.2 imports the wasip1 calls stay inside the component, and the host sees only WASI 0.2

Your code and the library under it make their wasip1 calls as before, and the adapter answers them from inside the component. The host on the other side only ever sees WASI 0.2. Rust's standard library for wasm32-wasip2 doesn't need the adapter at all. Its core module imports WASI 0.2 directly, even for std::fs: the fs build's core module imports get-directories and open-at itself.

The linker attaches the adapter whether or not the module calls any of it. Nothing in no-files does, so the adapter itself doesn't make it into the result. Its import of wasi:filesystem/types, the one interface this component also exports, does. If you stop the build after the first stage with -C link-arg=--skip-wit-component and run the second yourself with wasm-tools component new, the import isn't there.

The example takes a different route, a target with no WASI in it at all. On wasm32-unknown-unknown, the standard library still provides Vec and String but brings no WASI and no adapter, and wasm-tools component new turns the resulting core module into a component:

Bash
cargo build -p no-files --target wasm32-unknown-unknown
wasm-tools component new target/wasm32-unknown-unknown/debug/no_files.wasm \
  -o target/no_files.wasm

Now its imports are only what wasi:filesystem/types itself refers to:

Terminal output
import wasi:io/error@0.2.6;
import wasi:io/streams@0.2.6;
import wasi:clocks/wall-clock@0.2.6;
export wasi:filesystem/types@0.2.6;
export wasi:filesystem/preopens@0.2.6;

One component again

wac plug puts the two together, as it did for tally:

Bash
wac plug target/wasm32-wasip2/debug/service.wasm \
  --plug target/no_files.wasm -o bin/main.wasm

The composed component imports nineteen interfaces: the eighteen from The service World, plus wasi:clocks/wall-clock. All nineteen are in service-imports, and both wasi:filesystem interfaces are gone, satisfied inside. Viceroy starts it, and the handler runs:

Terminal output
fs: Err(Os { code: 44, kind: NotFound, message: "No such file or directory" })
handled

The file read still fails, but now it fails the way a missing file does, as an Err your code can handle, instead of the way a missing interface does.

Plugging no-files into the net build isn't enough. It removes the two wasi:filesystem imports, and the two clocks the net build adds are both in the service world, but the seven wasi:sockets interfaces are still there. Viceroy refuses it at the first one:

Terminal output
ERROR component imports instance `wasi:sockets/network@0.2.6`, but a matching implementation was not found in the linker

Running that build would take another component, one that exports wasi:sockets.

Beyond the WIT

For a binding, the practical consequence is that the check that matters is on the binary, not the source. Your code doesn't decide what a component imports. The standard library, the runtime, the linker, and every dependency get a say, and one call anywhere in the build is enough. Run wasm-tools component wit on what you're about to deploy and compare its imports against service-imports. Anything outside that list fails at instantiation.

The other consequence is that the list isn't final. If an import falls outside the world, it doesn't have to come from the host. A component can supply it, and the result is still one component the host accepts. That works across languages, since no-files would serve a component written in anything whose standard library reaches for wasi:filesystem. You don't have to write it by hand, either. The Bytecode Alliance's WASI Virt (opens in a new window) generates components like this one, and can mount a read-only filesystem into the component itself, so a program that reads a configuration file at startup can find one.

Either way, you're choosing behavior. no-files turns every file access into NotFound, and that's only correct if your program handles it. A supplied interface keeps the component from being refused, but it's on you to decide what the interface should do.

Next: we stop a build between its two stages and look at the core module the linker was about to turn into a component, and at what the second stage does to it.