The Wasm Component Model on Fastly Compute

Synthesizing a Response by Hand

EDIT (2026-08-18): corrected the quoted get-header-values doc comment, which had picked up wording from the header-names variant of the same function, and added a comparison against the real Rust SDK's buffer handling.

Last time, handle got called, logged a line, and returned. The client got back an empty 200. That's fine for proving the wiring works, but it's not a service. This time we actually answer.

"Send a response" sounds like it should be one function call away. It's closer to four: create a response, create a body, write into the body, then hand both to send-downstream. Nothing assembles these for you.

Creating a response

compute.wit's http-resp interface is explicit about what you get for free:

WIT
resource response {
  /// Create a new `response`.
  ///
  /// The new `response` is created with status code 200 OK, no headers, and an empty body.
  new: static func() -> result<response, error>;

  ...

  /// Sets a response header to the given value, discarding any previous values for the given
  /// header name.
  insert-header: func(
    name: string,
    value: list<u8>,
  ) -> result<_, error>;

  ...
}

You'd probably read resource response { ... } and reach for an OOP instinct: new looks like a constructor, insert-header looks like a method, and you'd be right to. A WIT resource really is close to a class: state you can't reach directly, plus a set of functions that operate on it. The mechanics back that up directly. new is marked static, meaning it takes no implicit self, the same role a static or associated constructor plays in a language like Rust (impl Response { fn new() -> Response }), called on the type itself before you have an instance to work with. Everything else in the block, insert-header included, does take that implicit self, which is why it reads as response.insert-header(...) rather than a value passed into a free function.

Where the analogy breaks is the boundary it sits behind. response isn't an object living in your program's own memory—it's a handle to something the host owns on the other side of the ABI. That's why there's no field access, ever: the interface only gives you the functions it chose to expose, and you never get to see what's actually inside.

Diagram comparing a Rust object, which lives entirely on the guest side of the ABI boundary, against a WASI resource, whose state lives on the host — the guest only ever holds a handle pointing across the boundary to it. Guest Host ABI boundary Rust object state lives right here entirely local — direct field access handle wasi resource state lives on the host guest holds a handle; the resource itself never crosses

response.new() hands you 200 OK with nothing attached—no Content-Type, no Content-Length, no body. Every header you want, you add yourself.

One last point on this WIT: unlike the header name, which is a string, the header value is list<u8>. HTTP header values aren't actually guaranteed to be valid UTF-8. HTTP itself allows byte values outside US-ASCII in a field value (RFC 9110 §5.5 (opens in a new window)'s obs-text, for legacy non-ASCII charsets like ISO-8859-1), so the ABI reflects that directly instead of pretending otherwise. A high-level SDK might hand you something string-shaped for convenience; at this layer, you're holding the real bytes.

Reading a request header

Reading is the mirror image, on the request side:

WIT
/// Gets the value of a header, or `none` if the header is not present.
///
/// If there are multiple values for the header, only one is returned. See
/// `get-header-values` if you need to get all of the values.
///
/// If header name requires more than `max-len` bytes, this will return an `error.buffer-len`
/// containing the required size.
get-header-value: func(
  name: string,
  max-len: u64,
) -> result<option<list<u8>>, error>;

Two things stand out. First, you pass a max-len (a buffer size cap) because there's no dynamically-growing string type handed back to you automatically; you're telling the host how much you're willing to receive, and it's your job to pick a number large enough. Second, the return type is a result wrapping an option: the outer result is "did this call fail," the inner option is "was the header even present." Those are two genuinely different failure modes, and the ABI won't let you collapse them into one the way a friendlier .get(name) might.

If you guess too small on max-len, you don't just get a generic failure—the ABI tells you exactly how big to make it next time. compute.wit's shared error type spells it out:

WIT
/// Buffer length error.
///
/// Returned when a buffer is the wrong size.
/// Includes the buffer length that would allow the operation to succeed.
buffer-len(u64),

error.buffer-len carries the required size as a payload. There's no separate "how big should this buffer actually be" call to make first—you just try a size, and if it's wrong, the failure tells you the right one.

There's a second reason header values come back as list<u8> rather than string, beyond the UTF-8 point above: a single header name can legitimately have more than one value on the wire (repeated header instances, not a comma-joined single line), and compute.wit handles that with a sibling function:

WIT
/// Gets multiple header values for the given `name` via a buffer of the provided size.
///
/// As opposed to `get-header-value`, this function returns all of the values for this header.
///
/// The first `cursor` values are skipped. The remaining values are encoded successively with
/// a NUL byte after each into a list of bytes at most `max-len` long. If any of the remaining
/// values don't fit, the returned `option<u32>` is the index of the first value that didn't
/// fit, or `none` if all the remaining values fit. If `max-len` is too small to fit any value,
/// an `error.buffer-len` error is returned, providing a recommended buffer size.
get-header-values: func(
  name: string,
  max-len: u64,
  cursor: u32
) -> result<tuple<list<u8>, option<u32>>, error>;

get-header-values packs however many values fit into one max-len-sized buffer, each one terminated by a \0, and tells you where to resume (cursor) if there were more than fit. get-header-value (singular) only ever returns one value—it doesn't multiplex anything itself—but it shares the same list<u8> representation as its plural sibling, since both are fundamentally "some bytes, possibly containing embedded structure," not text.

Wrapping the low-level bits

Retry-on-buffer-len and NUL-splitting aren't things you want to write inline at every call site. This is exactly the kind of low-level byte manipulation a real SDK exists to hide, so we write it once, ourselves:

Rust
/// Reads a single request header value, growing the buffer and retrying
/// if the ABI tells us the one we tried was too small.
fn get_header_value(
    request: &http_incoming::Request,
    name: &str,
) -> Result<Option<String>, http_req::Error> {
    let mut max_len: u64 = 128;
    loop {
        match request.get_header_value(name, max_len) {
            Ok(Some(bytes)) => return Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),1
            Ok(None) => return Ok(None),
            Err(http_req::Error::BufferLen(needed)) => max_len = needed,2
            Err(e) => return Err(e),
        }
    }
}

/// Reads every value for a request header, splitting the NUL-delimited
/// buffer the ABI hands back and paging through `cursor` until it says
/// there's nothing left.
fn get_header_values(
    request: &http_incoming::Request,
    name: &str,
) -> Result<Vec<String>, http_req::Error> {
    let mut values = Vec::new();
    let mut cursor: u32 = 0;
    let mut max_len: u64 = 256;

    loop {
        match request.get_header_values(name, max_len, cursor) {3
            Ok((bytes, more)) => {
                values.extend(
                    bytes
                        .split(|&b| b == 0)
                        .filter(|chunk| !chunk.is_empty())
                        .map(|chunk| String::from_utf8_lossy(chunk).into_owned()),1
                );
                match more {
                    Some(next_cursor) => cursor = next_cursor,3
                    None => return Ok(values),
                }
            }
            Err(http_req::Error::BufferLen(needed)) => max_len = needed,2
            Err(e) => return Err(e),
        }
    }
}

As we touched on above, HTTP itself defines header values to be bytes. However, in practice almost every request header you'll ever see is plain ASCII so we deem String::from_utf8_lossy to be enough for our purposes.

Neither function guesses a buffer size and hopes. Each starts with a reasonable size, and if the ABI comes back with error.buffer-len(needed), retries with exactly the size it was told to use.

get_header_values additionally keeps calling with an advancing cursor until the ABI reports none—meaning everything that was left actually fit.

Fastly's own Rust SDK draws this line in the same place, and names it. The fastly crate ships a low-level handle module (opens in a new window) sitting roughly where our two wrapper functions sit, and its documentation lists the reasons to reach for Request/Response instead. One of those reasons describes the loop above almost word for word: "Explicit buffer sizes are required to get data such as header values from the Compute host. If the size you choose isn't large enough, the operation will fail with an error and make you try again."

What that buys a caller is a much shorter surface. Response::from_status(200), set_header(name, value), and get_header_str(name) -> Option<&str> cover most of what we just wrote, with get_header(name) -> Option<&HeaderValue> still there for a value that genuinely isn't text. No max_len parameter anywhere, and no retry to write. That SDK is reading its headers over a different ABI than the WIT above, as Hello, World noted, but the buffer cap it's papering over is the same one, because it comes from the host rather than from any particular interface description.

Putting it together

Rust

mod bindings;

use bindings::{
    exports::fastly::compute::http_incoming,
    fastly::compute::{http_body, http_req, http_resp},
};

struct SimpleResponse;

impl http_incoming::Guest for SimpleResponse {
    fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
        let user_agent = get_header_value(&request, "user-agent")
            .map_err(|_| ())?
            .unwrap_or_else(|| "(none)".to_string());
        let accept_values = get_header_values(&request, "accept").map_err(|_| ())?;

        // A new response starts at 200, no headers, empty body. Nothing
        // is assembled for you.
        let response = http_resp::Response::new().map_err(|_| ())?;
        response
            .insert_header("content-type", b"text/plain")
            .map_err(|_| ())?;

        // A static method on the response resource above, but a free
        // function here — http-body never wraps body in a resource block.
        let body = http_body::new().map_err(|_| ())?;
        let msg =
            format!("Hello, world! Your user-agent is: {user_agent}\nAccept: {accept_values:?}\n");
        http_body::write(&body, msg.as_bytes()).map_err(|_| ())?;

        http_resp::send_downstream(response, body).map_err(|_| ())?;

        Ok(())
    }
}

bindings::export!(SimpleResponse with_types_in bindings);

We're still ignoring the request body, just the request's headers and the response now. response.new() and response.insert_header(...) are instance methods on the response resource, generated directly from the resource response { ... } block earlier; same for get_header_value and get_header_values on request, which is what our two wrapper functions call underneath. http_body::new and http_body::write, by contrast, are free functions—http-body never wraps its body type in a resource block, so there's no self to call them on.

send_downstream is the one that actually gets bytes to the client, and it's worth looking at directly:

WIT
/// Sends a response to the client that made the request passed to `http-incoming.handle`.
///
/// This method returns as soon as the response header begins sending to the client, and
/// transmission of the response will continue in the background.
///
/// Data for the body must be written before calling this function. To start a response
/// and write data to it afterwards, use `send-downstream-streaming` instead.
send-downstream: func(
  response: response,
  body: body,
) -> result<_, error>;

It lives in http-resp, same as resource response { ... }, but it sits alongside that block, not inside it, taking a response and a body as two plain parameters rather than being a method on either one. That's the free-function pattern again, for a plainer reason than http-body's: send-downstream needs a response and a body at once, and there's no single resource to hang a method like that off of. It also only returns once the response header has started sending: the doc comment's explicit that the body transfer itself continues in the background afterward, which is why there's a separate send-downstream-streaming for when you want to start responding before you've finished writing the body.

Running it

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

Bash
fastly compute serve

Let's try sending an Accept header as a single header line, joined by commas:

Bash
curl \
  -H "User-Agent: whoever-you-are" \
  -H "Accept: text/html, application/json" \
  http://127.0.0.1:7676/ (opens in a new window)

The response:

Terminal output
HTTP/1.1 200 OK
content-type: text/plain
content-length: 90

Hello, world! Your user-agent is: whoever-you-are
Accept: ["text/html, application/json"]

Note that when the single header line is sent, you get back a one-element Vec whose single value is "text/html, application/json", comma and all.

If the header is sent as two separate instances, not one comma-joined line:

Bash
curl \
  -H "User-Agent: whoever-you-are" \
  -H "Accept: text/html" \
  -H "Accept: application/json" \
  http://127.0.0.1:7676/ (opens in a new window)
Terminal output
HTTP/1.1 200 OK
content-type: text/plain
content-length: 92

Hello, world! Your user-agent is: whoever-you-are
Accept: ["text/html", "application/json"]

get-header-values splits repeated header instances on the wire; it has no opinion about comma-separated syntax inside a single instance, because that's an HTTP-semantic convention, not something the ABI parses for you.

A real header we set, two real headers we read back out of the request—one single-valued, one genuinely repeated—and a real body, all assembled by hand.

Beyond the WIT

The list<u8> on both sides of the header APIs is the detail worth sitting with. It's not an oversight—it's the ABI declining to make a promise it can't keep. Almost every request header you'll ever see is plain ASCII, and it's tempting to assume string would've been fine. But HTTP itself doesn't require that, and the moment the ABI typed these as string, it would be asserting a guarantee about the wire that isn't actually true. Bytes in, bytes out—you decide what to do with the ones that don't decode cleanly.

Next: we stop ignoring the request body, too, and read what's actually inside it.