The Wasm Component Model on Fastly Compute

The service World: WASI Without wasi:http

WASI 0.2 (opens in a new window) shipped with two standard worlds. wasi:cli/command is for command-line programs, and wasi:http/proxy is for components that handle incoming HTTP requests and send requests of their own. Fastly's post about the release (opens in a new window), from April 2024, described the proxy world as the one that "characterizes platforms, like Fastly, that can send and receive streaming HTTP requests and responses, serving as forward or reverse proxies." Search compute.wit for wasi:http, though, and you won't find it. The world Compute defines, service, imports eighteen WASI interfaces, and none of them is HTTP.

Hello, World and Environment each looked at a corner of that list. With the Fastly interfaces covered, it's worth reading the whole thing, next to the world WASI defines for exactly this kind of platform.

The service-imports world

WIT
/// Interfaces that a Fastly Compute service may import.
///
/// This contains the imports used in the `service` world, factored out into a
/// separate world so that it can be used by library components. Library components
/// are components that do not export anything themselves.
world service-imports {
  import wasi:clocks/wall-clock@0.2.6;
  import wasi:clocks/monotonic-clock@0.2.6;
  import wasi:io/error@0.2.6;
  import wasi:io/streams@0.2.6;
  import wasi:io/poll@0.2.6;
  import wasi:random/random@0.2.6;
  import wasi:random/insecure@0.2.6;
  import wasi:random/insecure-seed@0.2.6;
  import wasi:cli/environment@0.2.6;
  import wasi:cli/exit@0.2.6;
  import wasi:cli/stdout@0.2.6;
  import wasi:cli/stderr@0.2.6;
  import wasi:cli/stdin@0.2.6;
  /// Stdio streams in a Compute service are never terminals, so these
  /// interfaces always return `none`.
  import wasi:cli/terminal-stdin@0.2.6;
  import wasi:cli/terminal-stdout@0.2.6;
  import wasi:cli/terminal-stderr@0.2.6;
  import wasi:cli/terminal-input@0.2.6;
  import wasi:cli/terminal-output@0.2.6;

  ...
}

The ... is the Fastly half, every fastly:compute interface this series has covered. The WASI half comes from four packages: clocks, io, random, and cli. The service world adds one export to all of it:

WIT
world service {
  include service-imports;

  // Export the `http-incoming` interface.
  export http-incoming;
}

include copies every import and export of one world into another, so service is service-imports plus http-incoming.

Then there's the doc comment above the terminal interfaces in service-imports. Three of those interfaces, terminal-stdin, terminal-stdout, and terminal-stderr, each have one function that asks whether a stream is a terminal, and the doc comment promises they'll always answer none. The other two, terminal-input and terminal-output, hold nothing but the resource types a some answer would carry.

If no Compute service ever has a terminal, why does the world offer three ways to ask, plus the types for an answer that never comes? The reason for this is that your toolchain asks anyway, even on a platform where the answer is always none.

What does a component's ABI interface look like?

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

The example for this post declares a world that names three interfaces, http-body and http-resp to import and http-incoming to export.

wasm-tools (opens in a new window), a command-line toolkit for inspecting and transforming Wasm binaries, can print the world a built component actually ended up with. wasm-tools component wit lists what this one imports:

Terminal output
import fastly:compute/types@0.1.0;
import fastly:compute/async-io@0.1.0;
import fastly:compute/http-body@0.1.0;
import fastly:compute/http-resp@0.1.0;
import fastly:compute/http-req@0.1.0;
import wasi:io/poll@0.2.6;
import wasi:io/error@0.2.6;
import wasi:io/streams@0.2.6;
import wasi:cli/environment@0.2.6;
import wasi:cli/exit@0.2.6;
import wasi:cli/stdin@0.2.6;
import wasi:cli/stdout@0.2.6;
import wasi:cli/stderr@0.2.6;
import wasi:cli/terminal-input@0.2.6;
import wasi:cli/terminal-output@0.2.6;
import wasi:cli/terminal-stdin@0.2.6;
import wasi:cli/terminal-stdout@0.2.6;
import wasi:cli/terminal-stderr@0.2.6;
export fastly:compute/http-incoming@0.1.0;

The first five come from compute.wit itself. http-req is there because the exported handle takes a request, and types and async-io because http-body and http-resp use types from them.

The wasi: imports are a different story. The example doesn't print anything, read the environment, or exit, but they're there anyway. On this target, Rust's standard library is built on wasi-libc (opens in a new window), a C standard library for WASI, and the two expect a process-shaped environment, with stdio, an exit code, and a way to ask whether each stream is a terminal. The component imports all of it. A component written in another language would import whatever that language's standard library or runtime reaches for, which could be more of WASI or less.

compute.wit listing these imports and then documenting that they'll always return none actually makes a lot of sense. If a host left the terminal interfaces out, it would refuse this component, along with any Rust component that links the same stdio code. compute.wit doesn't say that's why, but it's the most likely reason. The documentation pays off on its own, too: if you're writing a host or a test harness, you can stub them out to return none without reading a line of the host's code. It's hard to find another function in the ABI you can say that about.

The world WASI defines for proxies

wasi:http 0.2.6 defines its proxy world in two parts:

WIT
/// The `wasi:http/imports` world imports all the APIs for HTTP proxies.
/// It is intended to be `include`d in other worlds.
@since(version = 0.2.0)
world imports {
  /// HTTP proxies have access to time and randomness.
  @since(version = 0.2.0)
  import wasi:clocks/monotonic-clock@0.2.6;
  @since(version = 0.2.0)
  import wasi:clocks/wall-clock@0.2.6;
  @since(version = 0.2.0)
  import wasi:random/random@0.2.6;

  /// Proxies have standard output and error streams which are expected to
  /// terminate in a developer-facing console provided by the host.
  @since(version = 0.2.0)
  import wasi:cli/stdout@0.2.6;
  @since(version = 0.2.0)
  import wasi:cli/stderr@0.2.6;

  /// TODO: this is a temporary workaround until component tooling is able to
  /// gracefully handle the absence of stdin. Hosts must return an eof stream
  /// for this import, which is what wasi-libc + tooling will do automatically
  /// when this import is properly removed.
  @since(version = 0.2.0)
  import wasi:cli/stdin@0.2.6;

  /// This is the default handler to use when user code simply wants to make an
  /// HTTP request (e.g., via `fetch()`).
  @since(version = 0.2.0)
  import outgoing-handler;
}

/// The `wasi:http/proxy` world captures a widely-implementable intersection of
/// hosts that includes HTTP forward and reverse proxies. Components targeting
/// this world may concurrently stream in and out any number of incoming and
/// outgoing HTTP requests.
@since(version = 0.2.0)
world proxy {
  @since(version = 0.2.0)
  include imports;

  /// The host delivers incoming HTTP requests to a component by calling the
  /// `handle` function of this exported interface. A host may arbitrarily reuse
  /// or not reuse component instance when delivering incoming HTTP requests and
  /// thus a component must be able to handle 0..N calls to `handle`.
  @since(version = 0.2.0)
  export incoming-handler;
}

Set that next to service-imports, and we see that every import wasi:http/imports names is in Compute's list except one, outgoing-handler. Compute keeps the clocks, the randomness, and the three stdio streams, and adds the rest of what a standard library expects: environment, exit, the terminal interfaces, and the two insecure random sources. It also names the three wasi:io interfaces, which wasi:http/imports reaches only indirectly, through the interfaces it does name. Then it swaps the HTTP parts. Instead of importing outgoing-handler and exporting incoming-handler, a Compute service imports http-req and exports http-incoming.

Two handlers

The exports are where the two designs part ways. Here's wasi:http's:

WIT
/// This function is invoked with an incoming HTTP Request, and a resource
/// `response-outparam` which provides the capability to reply with an HTTP
/// Response. The response is sent by calling the `response-outparam.set`
/// method, which allows execution to continue after the response has been
/// sent. This enables both streaming to the response body, and performing other
/// work.
///
/// The implementor of this function must write a response to the
/// `response-outparam` before returning, or else the caller will respond
/// with an error on its behalf.
@since(version = 0.2.0)
handle: func(
  request: incoming-request,
  response-out: response-outparam
);

And Compute's, from Hello, World:

WIT
/// Handle the given request.
///
/// This function is called once per sandbox. When it returns, the
/// sandbox exits. To opt into receiving multiple requests in a single
/// sandbox, use `http-downstream.next-request`.
///
/// To send a response for the given `request`, use `send-downstream`, or to
/// stream the response body after the response has been initiated, use
/// `send-downstream-streaming`.
handle: func(request: request, body: body) -> result;

They have more in common than they appear to. Both let you send the response and keep working: response-outparam.set "allows execution to continue," and send-downstream-streaming leaves the body open. The difference is in who decides how many requests a component sees. The proxy world says a host "may arbitrarily reuse or not reuse" an instance, so the component must handle any number of calls. Compute calls handle once per sandbox and makes reuse something you opt into with next-request, which is the deal Reusable Sandboxes described.

The outgoing side differs more. wasi:http's outgoing-handler takes a request and an optional request-options, and that resource has exactly three settings: a connect timeout, a first-byte timeout, and a between-bytes timeout. Those are the same three that Backend Options set on a dynamic backend. Compute puts them on a named backend, and send takes one, as Calling a Backend by Hand showed. In wasi:http, the request's scheme and authority are the whole story of where it goes. And every Compute send runs through the readthrough cache, which wasi:http's types don't mention anywhere.

The same errors

Send Errors went through send-error-detail, Compute's list of ways a backend request can fail. wasi:http has its own, error-code, for the same job. Of send-error-detail's 24 named cases (not counting extra), 17 also appear in error-code under the same name, give or take capitalization: dns-timeout, destination-unavailable, connection-limit-reached, tls-certificate-error, http-response-timeout, and twelve more.

Most of the overlap has a common source. error-code's doc comment says its cases "are inspired by the IANA HTTP Proxy Error Types," and 15 of the 17 shared names are on the list RFC 9209 (opens in a new window) registered there. The other two, http-request-uri-invalid and internal-error, aren't in the RFC. They're names wasi:http added, and Compute has them too.

What happens if we try to target WASI HTTP?

The example has a second crate, proxy-world, that targets wasi:http/proxy and nothing else. Viceroy won't start it:

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

That's not an error from the request. The handler never runs. Instantiating a component means supplying every one of its imports, and Viceroy has no wasi:http to supply, so it stops before it serves anything.

A real Compute service draws the line in the same place. Publishing the build as a new version of a working service uploads the package without complaint, 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:http/types@0.2.6`, but a matching implementation was not found in the linker

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

It appears that Compute has no wasi:http to supply, so to run on Compute, a component has to target Compute's world, not the proxy world.

That's where things stand today, and I'll add a guess of my own. Fastly was one of the four founding members of the Bytecode Alliance (opens in a new window) in 2019, along with Mozilla, Intel, and Red Hat. That's the group behind Wasmtime and much of the component tooling this series relies on, including wit-bindgen and the wasm-tools used above. Compute is also older than the worlds themselves. Fastly announced its beta (opens in a new window) in November 2019, more than four years before WASI 0.2 was approved in January 2024, so it needed an HTTP interface of its own long before a standard one existed.

Considering both, I'd like to think this is a transitional stage, and I wouldn't be surprised if a future version of compute.wit supported wasi:http/proxy directly. That's speculation on my part, not anything Fastly has announced.

The host isn't the only thing that can supply an import, though. When we get to guest-owned resources, a second component can supply one, and the two are combined at build time into a single component the host accepts.

Beyond the WIT

For a binding, the first consequence is practical. Anything built on wasi:http won't run on Compute, and the failure happens before your code does. That includes your language's standard HTTP client, if its component toolchain implements it on wasi:http, and any library that does the same.

The second consequence is the opportunity. The two designs are close enough that a binding can offer a wasi:http-shaped API and implement it on fastly:compute, and the comparison above says exactly which three questions that layer has to answer. An outgoing request in wasi:http names a URL, so the layer has to choose a backend for it, whether by mapping hosts to static backends or by registering a dynamic one per authority. The cache has to have a policy, because wasi:http has nowhere to say what it should do. And response-outparam has to become send-downstream or send-downstream-streaming. Errors are the easy part, because most of send-error-detail maps onto error-code by name.

Next: service-imports brings in wasi:io/poll, which defines a pollable. compute.wit defines one of its own in async-io. A component on Compute can hold both, and they aren't the same type.