The Wasm Component Model on Fastly Compute

Write-Front and Trailers: Both Ends of a Body

EDIT (2026-10-05): corrected when write-front works. It needs a body that nothing has been sent from yet, not one held in full; the /front example prepends to a backend body it never reads. Also corrected the trailer table: Viceroy does send the trailer over HTTP/2, and the earlier test had silently fallen back to HTTP/1.1. And clarified which service ran the backend trailer test.

write may write fewer bytes than you asked for. write-front, the function right after it in http-body, always writes all of them.

WIT
/// Writes to a body.
///
/// This function may write fewer bytes than requested; on success, the number of
/// bytes actually written is returned.
write: func(body: borrow<body>, buf: list<u8>) -> result<u32, error>;

/// Prepends bytes to the front of a body.
///
/// On success, this function always writes all the bytes of `buf`.
write-front: func(body: borrow<body>, buf: list<u8>) -> result<_, error>;

write returns a count because it can take part of what you gave it. Streaming a Request, by Hand leaned on that: a body headed for the network can only absorb so much at once. write-front returns nothing but success, so it has to take everything. The difference in signatures is a hint about where each one works, and the example below confirms it.

This post is about both ends of a body: bytes added to the front of it, and trailers, the headers that come after it.

Writing to the front

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

A body you haven't read is still just a body, and you can add to either end of it. The example's /front path fetches http-me's echo of a request and adds a line to the front of the backend's body before sending it on, without reading any of it:

Rust

mod bindings;

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

fn text_body(text: &str) -> Result<http_body::Body, ()> {
    let body = http_body::new().map_err(|_| ())?;
    http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
    Ok(body)
}

/// Proxies http-me's echo of this request, with a line added to the front
/// of a body this program never reads.
fn front() -> Result<(), ()> {
    let backend = backend::Backend::open("http_me").map_err(|_| ())?;
    let request = http_req::Request::new().map_err(|_| ())?;
    request.set_uri("https://http-me.fastly.dev/anything").map_err(|_| ())?;
    let (response, body) =
        http_req::send_uncached(request, http_body::new().map_err(|_| ())?, &backend)
            .map_err(|_| ())?;

    http_body::write_front(&body, b"// fetched through Compute\n").map_err(|_| ())?;

    // The backend's Content-Length is now wrong, but automatic framing
    // discards it and computes a new one.
    http_resp::send_downstream(response, body).map_err(|_| ())
}
Bash
curl http://127.0.0.1:7676/front
Terminal output
// fetched through Compute
{
  "args": "",
  "body": "",
  "headers": {
    "host": "http-me.fastly.dev"
  },
  "method": "GET",
  "origin": "unknown",
  "url": "/anything"
}

The backend's response and body went straight back downstream, with one line more than the backend sent. The Content-Length the backend sent no longer matched, and it didn't have to: as Framing Headers showed, automatic framing throws it away and counts the bytes itself.

/front-streaming tries the same thing on a body that's already on its way to the client:

Rust
/// Tries write-front on a body that's already streaming to the client.
fn front_streaming() -> Result<(), ()> {
    let response = http_resp::Response::new().map_err(|_| ())?;
    let body = http_body::new().map_err(|_| ())?;
    http_resp::send_downstream_streaming(response, &body).map_err(|_| ())?;
    http_body::write(&body, b"already sent\n").map_err(|_| ())?;

    let result = http_body::write_front(&body, b"too late\n");
    let text = format!("write-front after streaming started = {result:?}\n");
    http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
    http_body::close(body).map_err(|_| ())
}
Terminal output
already sent
write-front after streaming started = Err(Error::Unsupported)

Viceroy and a real Compute service agree: unsupported. The front of a streaming body has already been sent, and there's nothing left to put anything in front of. That's also why write-front can promise to take every byte. It only works on a body that nothing has been sent from yet, so the new bytes just go ahead of the rest and never wait on a connection to drain. The program doesn't have to hold the body, either: in /front, it was the backend's body, and the program never read a byte of it.

Trailers

A trailer is a header field sent after the body instead of before it. RFC 9110 §6.5 (opens in a new window) says they "can be useful for supplying message integrity checks, digital signatures, delivery metrics, or post-processing status information": things that aren't known until the content has been generated. gRPC, for example, reports each call's outcome in a trailer.

http-body has four functions for them:

WIT
/// Adds a body trailing header with given value.
append-trailer: func(
  body: borrow<body>,
  name: string,
  value: list<u8>,
) -> result<_, error>;

/// Gets the names of the trailers associated with this body.
///
/// ...
get-trailer-names: func(
  body: borrow<body>,
  max-len: u64,
  cursor: u32,
) -> result<tuple<string, option<u32>>, trailer-error>;

/// Gets the value for the trailer with the given name, or `none` if the trailer is not present.
///
/// If there are multiple values for this header, only one is returned, which may be
/// any of the values. See `get-trailer-values` if you need to get all of the values.
///
/// ...
get-trailer-value: func(
  body: borrow<body>,
  name: string,
  max-len: u64,
) -> result<option<list<u8>>, trailer-error>;

/// Gets multiple values associated with the trailer with the given name.
///
/// ...
get-trailer-values: func(
  body: borrow<body>,
  name: string,
  max-len: u64,
  cursor: u32
) -> result<tuple<list<u8>, option<u32>>, trailer-error>;

They're the header functions from Headers, moved onto body: the same paging with max-len and cursor, the same NUL-terminated lists, and the same split between a name as string and a value as list<u8>. Trailers live on the body because that's where they arrive, at its end, and not on the request or response that the headers belong to.

Only append-trailer returns the ordinary error. The three readers return their own type:

WIT
/// Trailers aren't available until the body has been completely transmitted, so this error
/// type can either indicate that the errors aren't available yet, or that an error occurred.
variant trailer-error {
  /// The trailers aren't available yet.
  not-available-yet,

  /// An error occurred.
  error(error),
}

"The errors aren't available yet" means the trailers. The point of the type is its first case: asking for trailers before you've read the body to the end isn't a failure, it's just early.

Sending a trailer

The /trailers path streams three lines and then adds a trailer holding the number of bytes it wrote, a value it couldn't have known when the headers went out:

Rust
/// Streams a body, then adds a trailer that depends on everything written.
fn trailers() -> Result<(), ()> {
    let response = http_resp::Response::new().map_err(|_| ())?;
    // Announce the trailer up front, as HTTP recommends.
    response.insert_header("trailer", b"x-body-length").map_err(|_| ())?;
    let body = http_body::new().map_err(|_| ())?;
    http_resp::send_downstream_streaming(response, &body).map_err(|_| ())?;

    let mut length = 0;
    for piece in ["one\n", "two\n", "three\n"] {
        length += http_body::write(&body, piece.as_bytes()).map_err(|_| ())?;
    }
    http_body::append_trailer(&body, "x-body-length", length.to_string().as_bytes())
        .map_err(|_| ())?;
    http_body::close(body).map_err(|_| ())
}

Over HTTP/1.1, a trailer goes after the final zero-length chunk. Over HTTP/2, it's a HEADERS frame after the last DATA frame. Here's where it arrived:

Client Viceroy Real service
curl --http1.1 --raw No trailer after the 0 chunk No trailer after the 0 chunk
curl --http1.1 --raw -H "TE: trailers" No trailer No trailer
HTTP/2: curl -v, with --http2-prior-knowledge locally and --http2 against the real service x-body-length: 14 x-body-length: 14

Over HTTP/1.1 the trailer never went out, even when the client said it would accept one with TE: trailers. Over HTTP/2, both hosts delivered it, with the right count.

Testing that locally takes --http2-prior-knowledge. Against a plain http:// URL, curl --http2 asks the server to upgrade the connection, Viceroy doesn't upgrade, and the request quietly stays on HTTP/1.1, where the trailer is dropped. The real service is reached over HTTPS, where TLS negotiates HTTP/2 up front, so plain --http2 is enough there.

Reading a trailer

The /echo-trailers path goes the other way. It asks for the request's trailer names before reading the body, reads the body to the end, and asks again:

Rust
fn trailer_names(body: &http_body::Body) -> String {
    match http_body::get_trailer_names(body, 1024, 0) {
        Ok((names, _)) => format!("{:?}", names.split('\0').filter(|n| !n.is_empty()).collect::<Vec<_>>()),
        Err(e) => format!("{e:?}"),
    }
}

/// Reads the request's trailers before and after reading its body.
fn echo_trailers(request_body: http_body::Body) -> Result<(), ()> {
    let mut report = format!("before reading: {}\n", trailer_names(&request_body));

    let mut bytes = 0;
    loop {
        let chunk = http_body::read(&request_body, 8192).map_err(|_| ())?;
        if chunk.is_empty() {
            break;
        }
        bytes += chunk.len();
    }
    report.push_str(&format!("read {bytes} body bytes\n"));
    report.push_str(&format!("after reading: {}\n", trailer_names(&request_body)));


    if let Ok(Some(v)) = http_body::get_trailer_value(&request_body, "x-checksum", 256) {
        report.push_str(&format!("x-checksum: {}\n", String::from_utf8_lossy(&v)));
    }

    let response = http_resp::Response::new().map_err(|_| ())?;
    http_resp::send_downstream(response, text_body(&report)?).map_err(|_| ())
}

curl can't send a trailer, so I used two small clients of my own: one that writes a chunked HTTP/1.1 request by hand, ending in x-checksum: abc123, and one that sends the same trailer as an HTTP/2 HEADERS frame. Over HTTP/1.1 against both hosts, and over HTTP/2 against the real service, the answer was the same:

Terminal output
before reading: TrailerError::NotAvailableYet
read 12 body bytes
after reading: []

The first line is trailer-error doing its job: before the body has been read, the trailers aren't available yet. The third line is the surprise. After the whole body was read, the list of trailers was empty, and the one the client sent was gone.

I also tried a backend's response. The example, deployed as a real Compute service, called its own /trailers path through a dynamic backend, once over HTTP/1.1 and once with the backend's grpc option, which made the connection HTTP/2, and read the response body to the end. Neither run found any trailers.

So in my tests, trailers went out to an HTTP/2 client, and nothing came in from any direction. The ABI describes both directions without any caveat, and the platform's behavior is narrower than that.

Beyond the WIT

Fastly's Rust SDK doesn't offer write_front at all. It calls write-front internally, though, and the way it uses it is worth knowing.

The SDK's Body puts a buffered reader in front of the host's body, so a program can read in any size it likes without a host call per read. The trouble is that a buffered reader reads ahead. If the program stops partway and passes the body on, by sending it or by converting it back to the raw handle, the bytes already sitting in the buffer have been taken out of the host body, and they'd be lost. So before it hands the body off, the SDK writes the unread buffer back to the front of the host body. write-front is the SDK's "unread."

The same trick powers a feature. Body::get_prefix_mut reads the first n bytes of a body, lets the program look at them or change them, and puts them back at the front when it's done. A program can inspect the beginning of a large response (sniffing a file type, or rewriting the first line of a playlist) and still pass the whole thing on without holding the rest in memory.

That's what makes write-front a building block for bindings, even though few applications would call it directly. Any binding that layers buffering over a host body needs a way to return what it read ahead, and one that wants to offer a peek at a body needs the same thing.

Trailers are the opposite case, a feature the ABI describes more completely than the platform delivers. The Rust SDK's answer is to put its trailer support in fastly::experimental: the BodyExt, StreamingBodyExt, and BodyHandleExt extension traits. Placement like that is a binding's way of saying that the functions exist and that you shouldn't build on them without testing against the platform you'll run on. When the ABI and the platform disagree, a binding that labels the difference is more useful than one that wraps the ABI faithfully and says nothing.

Next: send-downstream-pending hands a backend response to the client without the program waiting for it, and there are functions to edit that response's headers before it exists.