The Wasm Component Model on Fastly Compute

Resources Aren't Host-Only

EDIT (2026-10-18): added a diagram of what wac plug does, in "Putting them together."

Every resource this series has met has had the same owner. A request, a body, a backend, a KV Store, a cache entry: the host creates it, keeps its state, and hands your program a handle. Your program can call methods through the handle and drop it, but the thing itself lives on the other side of the boundary.

That's true of every resource in compute.wit, and it's easy to take it for a rule of the Component Model. It isn't. A resource belongs to whichever component implements the interface that declares it, and a component you write can be that owner.

Who owns a resource

A resource type is declared in an interface, and an interface is either imported or exported. The side that exports it provides the implementation. It decides what a value of that type actually is, keeps the state, and runs the destructor when the last handle goes away. The side that imports it gets handles and nothing else.

On Compute, the host provides every fastly:compute interface, and your program imports them. That's why every resource so far has been the host's. The only thing a Compute service exports is http-incoming, which has a function and no resources, so your program has never been on the owning side.

The example for this post puts it there. Its WIT declares a resource of its own, in a package called local:main:

WIT
package local:main;

/// A running total, owned by whichever component implements this interface.
interface tally {
  resource tally {
    constructor(name: string);
    /// Adds `n` and returns the new total.
    add: func(n: u32) -> u32;
  }
}

/// The component that implements `tally`.
world tally-provider {
  export tally;
}

/// A Compute service that uses `tally` without implementing it.
world service {
  import tally;
  import fastly:compute/http-body@0.1.0;
  import fastly:compute/http-resp@0.1.0;
  export fastly:compute/http-incoming@0.1.0;
}

Two worlds, one interface. tally-provider exports tally and owns the resource. service imports it, alongside the usual Compute interfaces, and holds handles.

The owning side

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

The tally crate builds the tally-provider world. wit-bindgen asks it for a Rust type to stand behind the resource, and that type is an ordinary struct:

Rust

wit_bindgen::generate!({ path: "../wit", world: "tally-provider", generate_all });

use std::cell::Cell;

use exports::local::main::tally::{Guest, GuestTally};

/// The resource's representation, which lives in this component's memory.
struct Tally {
    name: String,
    total: Cell<u32>,
}

impl GuestTally for Tally {
    fn new(name: String) -> Self {
        eprintln!("tally: created {name}");
        Tally { name, total: Cell::new(0) }
    }

    fn add(&self, n: u32) -> u32 {
        self.total.set(self.total.get() + n);
        self.total.get()
    }
}

impl Drop for Tally {
    fn drop(&mut self) {
        eprintln!("tally: dropped {} at {}", self.name, self.total.get());
    }
}


struct Provider;

impl Guest for Provider {
    type Tally = Tally;
}

export!(Provider);

This is the side compute.wit has never shown you. The state is a String and a Cell<u32> in this component's linear memory, and Drop is the resource's destructor.

The using side

The service crate builds the service world. To it, Tally is a handle type, the same kind of thing Request and Body have always been:

Rust

wit_bindgen::generate!({ path: "../wit", world: "service", generate_all });

use exports::fastly::compute::http_incoming;
use fastly::compute::{http_body, http_resp};
use local::main::tally::Tally;

fn respond(text: &str) -> Result<(), ()> {
    let response = http_resp::Response::new().map_err(|_| ())?;
    let body = http_body::new().map_err(|_| ())?;
    http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
    http_resp::send_downstream(response, body).map_err(|_| ())
}

struct Service;

impl http_incoming::Guest for Service {
    fn handle(_request: http_incoming::Request, _body: http_body::Body) -> Result<(), ()> {
        let requests = Tally::new("requests");
        let bytes = Tally::new("bytes");
        requests.add(1);
        bytes.add(40);
        let total = bytes.add(2);
        drop(bytes);
        eprintln!("service: dropped its handle to bytes");
        respond(&format!("bytes reached {total}\n"))
    }
}


export!(Service);

Nothing in this code knows or cares whether a Tally lives in the host or in another component.

Putting them together

Built separately, the two crates are two components, and service can't run on Compute yet. wasm-tools component wit lists import local:main/tally; among its imports, and no host provides that. The tool that fixes it is wac, the Bytecode Alliance's component composition tool. Its plug command takes one component's exports and wires them into another's matching imports:

Bash
wac plug target/wasm32-wasip2/debug/service.wasm \
  --plug target/wasm32-wasip2/debug/tally.wasm -o composed.wasm

The result is one component. Its import list has eighteen entries, all fastly:compute or wasi:, every one of them in the service world from The service World. local:main/tally is gone, because it's satisfied inside. As far as the host can tell, this is an ordinary Compute service.

Diagram of what wac plug does. Before: service.wasm imports fastly:compute interfaces, wasi interfaces, and local:main/tally, and exports http-incoming. Nothing outside provides local:main/tally, so that import is an unfilled hole. tally.wasm exports local:main/tally, which matches the hole. After wac plug: composed.wasm is one component that contains both. Inside it, tally's export fills service's import, so local:main/tally is satisfied inside. From the outside, composed.wasm imports only fastly:compute and wasi interfaces and exports http-incoming, like an ordinary Compute service. before service.wasm import fastly:compute/… import wasi:… import local:main/tally export http-incoming tally.wasm export local:main/tally match wac plug composed.wasm service.wasm import local:main/tally tally.wasm export local:main/tally local:main/tally is satisfied inside import fastly:compute/… import wasi:… export http-incoming from the outside, it's one component, and local:main/tally isn't an import anymore

Viceroy runs it like one, and so does a real Compute service:

Terminal output
bytes reached 42

Viceroy's log shows who did what:

Terminal output
tally: created requests
tally: created bytes
tally: dropped bytes at 42
service: dropped its handle to bytes
tally: dropped requests at 1

A real service's log, read with fastly log-tail, shows the same sequence. Look at the middle two lines. drop(bytes) in the service ran the destructor in the tally component, and finished, before the service printed its next line. Dropping a handle is a call into the owner, and the owner's cleanup runs right then. The same happened to requests when handle returned. It's the mechanism behind every host handle you've ever dropped, seen from the side that's normally hidden.

Where a handle points

Every handle this series has held is, underneath, a 32-bit number. What it's a number of hasn't mattered until now, because the other end was always the host.

The runtime keeps a handle table for each component instance (the Canonical ABI spec (opens in a new window) calls it the instance's handles table), and a handle is an index into it. Each entry records which resource type it's a handle to, whether it owns the resource or only borrows it, and a rep, the resource's representation.

Diagram of one handle, followed from the component that holds it to the object it stands for. On the left, the service component holds bytes, a Tally whose handle is 3, an index into the table. In the middle, the runtime keeps the service's handle table. Each entry has an index, a resource type, whether it owns or borrows, and a rep. Entries 1 and 2 are the request and body the host handed the service. Their reps, 17 and 23, are arbitrary example numbers that have meaning only to the host. Entry 3 is an owning tally handle with rep 0x1f40. On the right, inside the tally component's memory, the Tally struct with name bytes and total 42 sits at address 0x1f40, so the rep is a pointer to it. The service never sees 0x1f40, and tally never sees 3. service runtime tally bytes: Tally holds handle 3 service's handle table index type own rep 1 request yes 17 2 body yes 23 3 tally yes 0x1f40 gray reps are arbitrary example numbers that have meaning only to the host index 3 at address 0x1f40 Tally name: "bytes" total: 42 rep handle: an index entry: type, own or borrow, rep the object the rep finds the service never sees 0x1f40, and tally never sees 3

The rep is how the implementer finds its own object. It's an i32 chosen by the component that implements the resource, and it can mean whatever that component likes: a pointer, an index into an array, a key in a map. No other component can read it. Everyone else holds a handle, and when a method call or a drop reaches the implementer, the runtime passes the rep back so it can find the object again. It works like a coat check. Your ticket is the handle, and where the attendant actually hung the coat is the rep.

Who implements the resource What the rep is
tally, built with wit-bindgen A pointer to the Tally struct, so turning it back into an object is a cast
The host, for request, body, and the rest Whatever the host chose; your program never sees it

Here's what happened to bytes, step by step:

  1. The constructor. The service called Tally::new("bytes"), and the call landed in the tally component. wit-bindgen's glue moved the Tally struct onto the heap and passed a pointer to it, as the rep, to resource.new. That added an owning entry to tally's table and returned its index.
  2. The return. The constructor returns an owned handle, so returning it moved the entry. The runtime removed it from tally's table and added one with the same rep to the service's. The number inside the service's Tally is an index into the service's table, and it doesn't have to match the one tally saw.
  3. The method calls. add borrows self. The service passed its index, and the runtime looked up the rep. Since the callee was the component that implements tally, the runtime passed the rep itself instead of creating a borrowed handle, and the glue turned the pointer back into a &Tally.
  4. The drop. drop(bytes) called resource.drop with the service's index. The runtime removed the entry, saw that it was owning, and called tally's destructor with the rep. The glue turned the pointer back into a Box<Tally> and dropped it, which ran Drop.

Neither component ever saw the other's number. The service held an index it couldn't interpret, and tally held a pointer it never gave away. The runtime's tables translated between them. Host resources work the same way, with the host as the implementer: every request handle you've held is an index into your component's table, and the rep behind it is the host's business.

Beyond the WIT

For a binding, guest-owned resources are a second half of the job that compute.wit alone never asks for.

Importing a resource means wrapping a handle: a type that holds the handle, forwards method calls, and drops the handle when the wrapper goes away. Every Compute binding has to do that well. Exporting a resource is the reverse. The binding picks a rep for each object and passes it to resource.new when the constructor runs, turns the rep back into the object on each method call, and runs cleanup when the destructor receives the rep. wit-bindgen did all of that for Tally without being asked, using a pointer as the rep, but a hand-written binding, or a generator for a new language, has to build it.

In a garbage-collected language, the exporting side has a trap the importing side doesn't. When your program imports a resource, losing track of a handle is a leak, and a finalizer can clean it up. When your program exports one, another component holds a handle to an object in your heap, and your collector can't see that reference. The binding has to keep the object alive itself, usually by holding it in a table keyed by rep, until the destructor call says the last handle is gone. A .NET or JavaScript binding that skips this step works until the first collection, and then a perfectly valid handle points at nothing.

Composition is also a way to split a program across languages. The tally component could have been written in anything that targets components, and the service wouldn't change. A binding can ship a piece once, as a component, instead of reimplementing it in every language that needs it.

Next: we take that last claim at its word, and write tally in another language without touching the service.