The Wasm Component Model on Fastly Compute

The Body Isn't Part of the Request (or the Response)

EDIT (2026-08-18): restored the rest of get-known-length's doc comment, which carries a caveat worth knowing, and added a comparison against the real Rust SDK's Body/StreamingBody.

Twice now I've told you we're ignoring the HTTP body. First because we ignored everything; second because we'd only gotten as far as headers. It would make sense for the body to be just another field on request and response, something you'd read off them the way you read a header. It isn't.

In fact, it's not attached to either one.

A body is its own thing

Look at where body actually comes from:

WIT
interface http-body {
  use types.{error};

  /// An HTTP request or response body.
  use async-io.{pollable as body};

  ...
}

body isn't defined inside http-req or http-resp at all. It's its own interface, and request/response each pull it in separately:

WIT
interface http-req {
  ...
  use http-body.{body};
  ...
}

interface http-resp {
  ...
  use http-body.{body};
  ...
}

The two different interfaces reach for the exact same type. That's not an accident of organization—it's the point. A body resource doesn't know or care whether it:

  • came from an incoming request
  • is destined for an outgoing response
  • was instantiated by your code using http-body.new(), or
  • was obtained from somewhere else entirely

For example, a KV Store value, once we get there in a later post, is handed to you as a body too, meaning you can take what you fetched and hand it straight to send-downstream as-is. Nothing about the type ties it to one role.

Here's how far that goes: you can take the incoming request's body and hand it directly to send-downstream as the outgoing response body, without ever reading it yourself.

Rust
fn handle(_request: http_incoming::Request, request_body: http_body::Body) -> Result<(), ()> {
    let response = http_resp::Response::new().map_err(|_| ())?;
    response.insert_header("content-type", b"text/plain").map_err(|_| ())?;

    // The incoming request body, handed straight to send-downstream as the
    // outgoing response body. We never read a single byte of it ourselves.
    http_resp::send_downstream(response, request_body).map_err(|_| ())?;

    Ok(())
}

If you run curl -X POST -d "echo this back verbatim" ... against the above code, you get back exactly echo this back verbatim. Nothing was copied, and nothing was parsed. The same handle just changed roles.

Buffers you can't rewind

A body resource isn't a byte array sitting in memory somewhere that you can index into. It's read and written through explicit calls, and the read side and write side don't know about each other:

WIT
/// Reads from a body.
read: func(body: borrow<body>, chunk-size: u32) -> result<list<u8>, error>;

/// 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>;

In this WIT snippet we see the borrow<T> construct being used. wit-bindgen compiles borrow<T> to Rust's own &T (a borrow). This means the handle you pass in isn't consumed by the call: read/write only get to use it for the duration of that one call, and you keep it afterward. That's not a stylistic choice: read is meant to be called again and again on the same body until you get an empty chunk back, and that only works if the first call leaves your handle intact for the second.

A body is a one-way pipe of data-in, data-out. There's no seeking backward to re-read something you already consumed: once you've read a chunk, it's gone from the buffer. Likewise, there's no way to retract something you've already written: once you've written a chunk, it's no longer in your hands.

You'll notice, by the way, that the read function has a similar shape as the header functions from last time: you pass a chunk-size, and there's not a promise of "give me everything."

There is a best-effort way of querying the buffer's length, but there's no guarantee you know the total size ahead of time:

WIT
/// Returns a `u64` body length if the length of a body is known, or `none` otherwise.
///
/// If the length is unknown, it is likely due to the body arising from an HTTP/1.1 message with
/// chunked encoding, an HTTP/2 or later message with no `content-length`, or being a streaming
/// body.
///
/// Receiving a length from this function does not guarantee that the full number of
/// bytes can actually be read from the body. For example, when proxying a response from a
/// backend, this length may reflect the `content-length` promised in the response, but if the
/// backend connection is closed prematurely, fewer bytes may be delivered before this body
/// handle can no longer be read.
get-known-length: func(body: borrow<body>) -> option<u64>;

Read that last paragraph twice, because it's stronger than "sometimes there's no length." Even a some(n) answer isn't a promise that n bytes will arrive. It's a report of what the length is claimed to be, which for a proxied response means whatever Content-Length the backend sent, and a connection that drops halfway through doesn't retroactively change that number. Sizing a buffer off get-known-length is fine; treating it as a count you're owed isn't.

This means that to actually get everything out of a body, you read in a loop until you get an empty chunk back:

Rust
// Read until the ABI hands back an empty chunk. There's no
// "give me everything" call, and no seeking back for a second look.
let mut buf = Vec::new();
loop {
    let chunk = http_body::read(&request_body, 8192).map_err(|_| ())?;
    if chunk.is_empty() {
        break;
    }
    buf.extend_from_slice(&chunk);
}
let text = String::from_utf8_lossy(&buf).into_owned();

One thing I noticed at this point was that functions like read/write/get-known-length aren't members of body itself. Why is that? It turns out we should go back to where body actually came from: use async-io.{pollable as body}.

http-body doesn't declare that resource: it aliases the type, under a local name, from async-io. A WIT interface can only attach methods inside the resource { ... } block that declares a resource; as body is only an alias it's not possible to extend it. So http-body has no way to make read/write/get-known-length methods on body—the only option left is a free function that takes a body handle as a parameter, which is exactly what these are.

Resources, owned by the platform

Creating a body works the same way it did when we built a response body last time:

WIT
/// Creates a new empty body that can be used for outgoing requests and responses.
new: func() -> result<body, error>;

Remember that body is a resource, so the actual object lives on the host side, meaning that http-body.new() is the only way to get an empty one: there's no other constructor, and no way to conjure a body value out of nothing on the guest side. Every body you ever hold either came from new, or came from the platform (an incoming request, a fetched KV value, a backend response).

That ownership shows up again when a body's done:

WIT
/// Frees a body.
///
/// This releases resources associated with the body.
///
/// For streaming bodies, this is a *successful* stream termination, which will signal
/// via framing that the body transfer is complete.
///
/// If a handle is dropped without calling `close`, it's an *unsuccessful* stream
/// termination.
close: func(body: body) -> result<_, error>;

close takes a bare body rather than a borrow<body>, and that's handing ownership over for good, instead of lending it out.

For a body you're streaming data into, calling close and merely dropping the handle aren't the same thing—one tells the client "that's everything, on purpose," the other looks like the connection died mid-transfer. It's not just bookkeeping; it changes what the other end of the wire actually sees.

You can also build one body out of another without manually copying bytes through your own code:

WIT
/// Appends the contents of the body `src` to the body `dest`.
append: func(dest: borrow<body>, src: body) -> result<_, error>;
Rust
// append composes two bodies without the bytes ever passing
// through your own code.
let out_body = http_body::new().map_err(|_| ())?;
http_body::write(&out_body, b"You sent:\n").map_err(|_| ())?;
http_body::append(&out_body, request_body).map_err(|_| ())?;

That writes a prefix line, then appends the entire request body (so long as it hadn't been read out of) after it: You sent:\nappended content here, without ever pulling those bytes through our own code the way the manual read loop above does.

Both of those ideas survive into Fastly's Rust SDK, which is a decent sign they aren't artifacts of the ABI's low-level shape. fastly::Body (opens in a new window) is documented as "An HTTP body that can be read from, written to, or appended to another body": the same three verbs, and the same refusal to define it as part of a request or a response. It implements Read, Write, and BufRead, so the manual chunk loop above collapses into ordinary Rust I/O, and append(&mut self, other: Body) is http-body.append wearing a Rust signature.

The close-versus-drop distinction makes it across too, in the type that SDK uses for a body still being written. StreamingBody (opens in a new window) has a finish() method, and its documentation spells out what happens if you skip it: "A streaming body will be automatically aborted if it goes out of scope without calling finish()." Those are the same two endings with the same wire-visible difference between them, expressed through a method call and a Drop impl rather than an owned handle and a dropped one.

Readiness

body isn't just similar to a pollable—go back to that first WIT snippet: it is one (use async-io.{pollable as body}). Every pollable has this:

WIT
resource pollable {
  /// Make a nonblocking attempt to complete the I/O operation.
  ///
  /// Returns `true` if the given async item is “ready” for its associated I/O action, `false`
  /// otherwise.
  ///
  /// ...
  is-ready: func() -> bool;

  ...
}

Unlike read/write/get-known-length above, this one really is a method call, not a free function—because body is pollable, not just shaped like it, that method is just there on any body handle you're holding:

Rust
let ready = request_body.is_ready();
println!("request body ready before first read: {ready}");

For a body you're reading, "ready" means there are new bytes available to read without blocking. async-io also has select/select-with-timeout, for waiting on several pollables—a body among them—at once; that's a bigger concept we'll come back to properly when we get to concurrency. For now, just knowing a body is really a pollable is enough to explain why is-ready is sitting right there on it, unexplained, the first time you go looking.

Running it

Reading everything out of the body by hand (full example (opens in a new window)):

Bash
curl -X POST -d "hello from the request body" http://127.0.0.1:7676/ (opens in a new window)
Terminal output
You sent 27 bytes: hello from the request body

console output:

Terminal output
request body ready before first read: true
read 27 bytes from request body: "hello from the request body"

And the passthrough version, from the same request body, never read at all (full example (opens in a new window)):

Bash
curl -X POST -d "echo this back verbatim" http://127.0.0.1:7676/ (opens in a new window)
Terminal output
echo this back verbatim

Beyond the WIT

is-ready isn't a courtesy method bolted onto a plain buffer. It's evidence of what a body actually is underneath. A body you're reading can have the platform writing into it—bytes arriving from the client—at the same time your own code is reading out of it, and those two sides don't proceed in lockstep. "Ready" is the honest answer to "has anything new shown up since I last checked," not a formality. Read and write pointers being independent isn't an implementation detail bolted on for convenience; it's what makes a body a live, two-sided pipe instead of a value you're handed all at once.

Next: we make an outbound request of our own, to a backend—and it turns out everything we just learned about bodies here applies just as much to the request going out as it did to the one coming in.