The Wasm Component Model on Fastly Compute
Headers: Listing, Writing, and Removing Them
October 1, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-10-02): corrected two claims about the ABI. request and response don't share a doc comment for get-header-names, only a shape, and the status functions are on response alone and had already appeared in earlier posts.
Synthesizing a Response settled how to read a header: one value with get-header-value, or every value with get-header-values, paging through a NUL-delimited buffer with a cursor. Since then, every example has set headers one at a time with insert-header and read the ones it already knew the names of.
That leaves three things a real program needs sooner or later: finding out which headers are there, giving one name more than one value, and taking a header away. The rest of the header functions cover them. The status code gets a proper look too, after several posts of setting it in passing, and so does the HTTP version, which nothing has called yet.
Listing the names
request and response both have get-header-names, with doc comments that differ only in which resource they name. Here's the request's:
/// Reads the request's header names via a buffer of the provided size.
///
/// The first `cursor` names are skipped. The remaining names are encoded successively with
/// a NUL byte after each into a list of bytes at most `max-len` long. If any of the remaining
/// names don't fit, the returned `option<u32>` is the index of the first name that didn't fit,
/// or `none` if all the remaining names fit. If `max-len` is too small to fit any name,
/// an `error.buffer-len` error is returned, providing a recommended buffer size.
get-header-names: func(
max-len: u64,
cursor: u32,
) -> result<tuple<string, option<u32>>, error>;
It pages exactly the way get-header-values does. The one difference is the return type: names come back as a string, where values come back as list<u8>. Header names are restricted to ASCII token characters by the HTTP specification, so the ABI can promise text for them. Values can't make that promise, as Synthesizing a Response explained. downstream-original-header-names has the same split.
The doc comment says "encoded successively with a NUL byte after each", even though the result is a string. So a string holding host\0accept\0 is a valid return value, and splitting it on \0 leaves an empty piece at the end, which you throw away.
Writing more than one value
insert-header is the one every example has used. It discards whatever the header held before and leaves exactly one value. Two more functions write values, and they're the ones that can leave more than one:
/// Sets the values for the given header name, replacing any headers that previously existed for
/// that name.
set-header-values: func(
name: string,
/// contains multiple values each terminated by `\0` and concatenated
values: list<u8>
) -> result<_, 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>;
/// Add a response header with given value.
///
/// Unlike `set-header-values`, this does not discard existing values for the same header name.
append-header: func(
name: string,
value: list<u8>,
) -> result<_, error>;
append-header adds one more instance of a header and leaves the existing ones alone. set-header-values replaces them all at once with a list you've encoded yourself, in the same NUL-terminated format get-header-values hands back.
The case where this matters most is Set-Cookie. Most headers that repeat can also be sent as one line with the values joined by commas, and a recipient treats the two forms the same. Set-Cookie is the exception: RFC 9110 §5.3 (opens in a new window) notes that it "cannot be combined into a single field value," because a cookie's attributes can contain commas. Two cookies need two Set-Cookie lines, so you need append-header, or set-header-values with two entries. Calling insert-header twice leaves you with only the second cookie.
Look closely at the doc comment on values: each value is terminated by \0. The NUL isn't a separator between values. It comes after every one of them, the last one included. The example below writes the same kind of list twice, once with the final NUL and once without, to see what that difference does.
Removing a header
/// Remove all response headers of the given name
///
/// Returns `ok` if any headers were successfully removed.
remove-header: func(name: string) -> result<_, error>;
remove-header returns ok when it removed something. The doc comment doesn't say what happens when there was nothing to remove, but the implication is an error, and that's what the example gets.
Status and version
/// Gets the HTTP status code of the response.
get-status: func() -> result<http-status, error>;
/// Sets the HTTP status code of the response.
set-status: func(status: http-status) -> result<_, error>;
These two are on response only, since a request has no status. http-status is a type alias in http-types, type http-status = u16, which puts a name on the number without constraining it. Both functions have turned up in passing already: the ACLs example set a status to answer with, and Send Errors read the status of a backend's response.
The version is on both resources, as a getter and setter over an enum:
/// HTTP protocol versions.
enum http-version {
/// HTTP/0.9
http09,
/// HTTP/1.0
http10,
/// HTTP/1.1
http11,
/// HTTP/2.0
h2,
/// HTTP/3.0
h3
}
On an incoming request, get-version tells you which protocol the client used. On a response, set-version sets the version the response goes out with. In practice, Viceroy honors it for the downstream response: setting http10 changes the status line to HTTP/1.0.
Putting it together
Full working code: full example on GitHub (opens in a new window).
The example lists the incoming request's header names with a deliberately small buffer, builds a response using each of the writing functions, removes one header and tries to remove another that was never set, sets a status, and then lists the response's own headers to show what they hold.
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{http_body, http_resp, types},
};
/// Collects every header name by paging through a names function with a
/// deliberately small buffer, noting each page in `log`.
fn all_names(
mut page: impl FnMut(u64, u32) -> Result<(String, Option<u32>), types::Error>,
mut max_len: u64,
log: &mut String,
) -> Result<Vec<String>, types::Error> {
let mut names = Vec::new();
let mut cursor = 0;
loop {
match page(max_len, cursor) {
Ok((buf, next)) => {
// Each name is followed by a NUL, so the last split is empty.
let got: Vec<&str> = buf.split('\0').filter(|n| !n.is_empty()).collect();
log.push_str(&format!(" cursor {cursor}: {got:?}, next {next:?}\n"));
names.extend(got.into_iter().map(String::from));
match next {
Some(n) => cursor = n,1
None => return Ok(names),
}
}
// Not even one name fit, so take the size the host suggests.
Err(types::Error::BufferLen(needed)) => max_len = needed,
Err(e) => return Err(e),
}
}
}
/// Reads every value of one response header, split on the NUL terminators.
fn values(response: &http_resp::Response, name: &str) -> Vec<String> {
match response.get_header_values(name, 1024, 0) {
Ok((buf, _)) => buf
.split(|b| *b == 0)
.filter(|v| !v.is_empty())
.map(|v| String::from_utf8_lossy(v).into_owned())
.collect(),
Err(_) => Vec::new(),
}
}
struct Headers;
impl http_incoming::Guest for Headers {
fn handle(request: http_incoming::Request, _body: http_body::Body) -> Result<(), ()> {
let mut report = String::new();
// Every header name on the incoming request, 24 bytes at a time.
report.push_str("request header names:\n");
let names = all_names(|len, cur| request.get_header_names(len, cur), 24, &mut report)1
.map_err(|_| ())?;
report.push_str(&format!(" all: {names:?}\n"));
let version = request.get_version().map_err(|_| ())?;
report.push_str(&format!("request version: {version:?}\n\n"));
let response = http_resp::Response::new().map_err(|_| ())?;
// insert-header replaces; append-header adds another instance.
response.insert_header("content-type", b"text/plain").map_err(|_| ())?;
response.append_header("set-cookie", b"theme=dark").map_err(|_| ())?;
response.append_header("set-cookie", b"lang=en").map_err(|_| ())?;
// set-header-values takes every value at once, each one terminated
// by a NUL, and replaces whatever was there.
response
.set_header_values("x-color", b"red\0green\0blue\0")2
.map_err(|_| ())?;
// The same call without the final NUL.
response
.set_header_values("x-shape", b"circle\0square")2
.map_err(|_| ())?;
// Removing a header that exists, and one that never did.
response.insert_header("x-temporary", b"1").map_err(|_| ())?;
let removed = response.remove_header("x-temporary");
let not_there = response.remove_header("x-never-set");3
report.push_str(&format!("remove-header(\"x-temporary\") = {removed:?}\n"));
report.push_str(&format!("remove-header(\"x-never-set\") = {not_there:?}\n\n"));
response.set_status(207).map_err(|_| ())?;
let status = response.get_status().map_err(|_| ())?;
report.push_str(&format!("response status: {status}\n"));
report.push_str("response headers, read back:\n");
let mut scratch = String::new();
let names = all_names(|len, cur| response.get_header_names(len, cur), 256, &mut scratch)
.map_err(|_| ())?;
for name in names {
report.push_str(&format!(" {name}: {:?}\n", values(&response, &name)));
}
let body = http_body::new().map_err(|_| ())?;
http_body::write(&body, report.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(Headers with_types_in bindings);
all_names takes the paging call as a closure, so the same loop serves both request.get-header-names and response.get-header-names. The two are separate functions on separate resources in the WIT, with identical shapes.
Running it
fastly compute serve
curl -i -H "Accept: text/plain" -H "X-Custom-Thing: hi" http://127.0.0.1:7676/
HTTP/1.1 207 Multi-Status
content-type: text/plain
set-cookie: theme=dark
set-cookie: lang=en
x-color: red
x-color: green
x-color: blue
x-shape: circle
content-length: 496
date: Sun, 27 Sep 2026 05:53:18 GMT
request header names:
cursor 0: ["host", "user-agent", "accept"], next Some(3)
cursor 3: ["x-custom-thing"], next None
all: ["host", "user-agent", "accept", "x-custom-thing"]
request version: HttpVersion::Http11
remove-header("x-temporary") = Ok(())
remove-header("x-never-set") = Err(Error::InvalidArgument)
response status: 207
response headers, read back:
content-type: ["text/plain"]
set-cookie: ["theme=dark", "lang=en"]
x-color: ["red", "green", "blue"]
x-shape: ["circle"]
The first page held three names. host\0user-agent\0accept\0 is 23 bytes, and the fourth name didn't fit in the remaining one, so next came back as Some(3): the index of the first name that didn't fit, which is also the cursor for the next call. The second page picked up x-custom-thing and returned None.
x-color arrived as three separate header lines, one per NUL-terminated value. x-shape lost square. Without a final NUL, square was never terminated, and whatever follows the last NUL is discarded, locally and on a real service alike. The doc comment's "each terminated by \0" means exactly what it says.
Removing a header that wasn't there failed with invalid-argument, which fits the doc comment's "returns ok if any headers were successfully removed," read the other way around.
The two Set-Cookie values went out as two lines. The header names came back lowercase, even though the request sent Accept and X-Custom-Thing, so don't count on get-header-names preserving capitalization. If you need the capitalization the client actually used, that's what downstream-original-header-names is for.
Run it again with curl --http1.0 and the version line says HttpVersion::Http10.
Published to a real Compute service, the same request gets the same headers, the same dropped square, and the same invalid-argument. Two things differ. The header names come back in a different order, with host last, so the first 24-byte page held only user-agent and accept. And a plain curl to the service negotiates HTTP/2, so the version line says HttpVersion::H2. Nothing in the doc comments promises an order for get-header-names, and this is a good reason not to depend on one.
Fastly's Rust SDK has the same set of operations on its Request and Response types, with names that line up closely: append_header adds a value, set_header replaces them all with one, get_header_names returns an iterator of names, and remove_header takes a header away. remove_header returns an Option<HeaderValue> holding one of the removed values, or None if there wasn't one, so "nothing to remove" is an ordinary answer rather than an error.
Beyond the WIT
Every function in this post is a call across the component boundary. The headers live on the host, inside the request or response resource, and the guest only ever holds a handle. Listing names, reading values back, and checking whether a header exists before removing it are each a separate crossing, with a buffer to size and sometimes a cursor to follow.
A binding decides where the authoritative copy of the headers lives, and there are two reasonable places. One is on the host: every read and write goes straight through, the guest keeps nothing, and the two can never disagree. The other is in the guest: headers are loaded into a guest-side map when first needed, reads come from the map, and writes update both.
The Rust SDK does a bit of both. Its Request and Response wrap a lazily populated guest-side header map. A request or response the host gave you (the incoming request, or a backend's response) loads header values into the guest only when you ask for them, and writes each change through to the host handle immediately. One you built yourself stays entirely in guest memory until something needs a host handle, typically the moment you send it. At that point the SDK creates the host resource and appends every header to it, one call per value.
That design also explains how the SDK makes remove_header return Option rather than an error. Before it asks the host to remove anything, it finds out whether the header had a value, from its own map or by reading the host's. If there was nothing there, it never makes the call, so the invalid-argument the example got can't happen.
The cost of a guest-side copy is that two copies exist, and the binding is responsible for keeping them consistent. Every write has to reach both, and every path that hands the resource to the host, whether sending it or passing it to another host function, has to flush the guest's changes first. Miss one path and the host acts on headers the guest thinks it already changed.
Next: two headers you aren't really in charge of. Content-Length and Transfer-Encoding get decided for you, unless you ask the ABI to let you decide.