Skip to main content

WebAssembly Components and wasmCloud Actors: A Glimpse of the Future

Β· 16 min read

wasm

Today we thought we would give you a glimpse of the future of WebAssembly and wasmCloud. As wasmCloud maintainers, we've always had a goal to follow all standards in the WebAssembly community. However, our other goal has been to create a platform on which you could leverage the power of Wasm for real projects. For the last few years, these two goals have been somewhat at odds with one another due to the bleeding-edge nature of Wasm. We've had to bridge the gap between Wasm's current state and the requirements needed to do Something Realβ„’ with it. This is starting to change!

The Component Model​

In the past year, the standards around Wasm and WASI have started to solidify and become reality. One of the most interesting emerging standards has been the Component Model. The TL;DR of the component model is that you are able to glue together arbitrary Wasm modules that import or export functions, as specified by an interface file. These interface files are called wit files (Wasm Interface Types) and allow for language agnostic code generation. This code is what handles converting the raw numbers of plain Wasm (i.e. integers and bytes) into concrete types. If you are familiar with wasmCloud already, this is very similar to what we call "contract driven development," which we use to separate non-functional requirements from business logic. Still confused? Don't worry, we'll be using some specific examples below. If this topic interests you and you'd like more information, we highly recommend you check out all of the documentation and examples that are available for the component model.

A New Way to Build Actors​

So at this point, you are probably wondering "What does this have to do with wasmCloud? Don't you already have your own contract stuff and RPC protocol?" Good question! Let's dive into this.

We recently created a proof of concept that shows our kvcounter example using the Component Model to provide all the necessary logic that used to be provided by our wasmbus-rpc Rust crate and other language specific libraries. Please note that this is not a fully functional example that can run in wasmCloud currently; it's meant to show how we can glue together various components, call the actor from the host, and then have the actor send data back to the host. We'll break down all the different parts of this example below, but you can find the actual source code here.

Old vs new​

components-diagram

As you can see in the diagram above, there was a lot of stuff that had to be done inside of language-specific and user-managed code. In fact, we just finished writing all of this code for Go as well, and it was a heavy lift! In the future version, things become significantly more modular. The communication to the host (which then gets sent over our RPC layer, the lattice) is handled by one module, and then we just provide a single Wasm module that satisfies each interface. This highlights why we are excited about Wasm. Wasm is language agnostic, which means that instead of having to create special wasmbus-rpc libraries for every single language we want to support, and then generating code for every interface, we can now write a single module in whatever language we want. Then, we can use that module to provide the necessary code to a wasmCloud actor written in any other language. If that doesn't make you excited, we don't know what will.

The detailed view​

From here on out, we are going to go into the specifics of how everything works. This will likely be useful to anyone wanting to experiment with the component model or who wants to better understand how it works. If that doesn't interest you, please feel free to skip down to the "What did we learn?" section

We're going to go from the top to the bottom as represented in the diagram above to explain what each component does, so please go back and reference it if anything below is confusing. Then we'll explain how we linked and ran the example.

The httpserver "receiver"​

Under the new paradigm, each wasmCloud interface will need to have two different WebAssembly modules: a sender and a receiver. The receiver half is used when the actor needs to receive a message from the host it is running on (hence the name). In wasmCloud, this means the host will receive a message on the lattice and then invoke the receive method of the actor. This first module of the proof of concept is the httpserver contract receiver. In order to implement this, the module needs to import one interface and export another. Here are what the interfaces look like:

wasmbus_receiver.wit

// These are importing some common types that you see in the receive function signature. See the
// actual code on Github if you are curious what these types look like
use * from error-type
use * from wasmbus-common

receive: function(msg: message) -> expected<payload, rpc-error>

You'll see that this interface has single function, the receive function that allows an actor to receive a message. We'll see how this works in code below

httpserver.wit

use * from error-type

type header-map = list<tuple<string, string>>

record http-request {
    // HTTP method. One of: GET,POST,PUT,DELETE,HEAD,OPTIONS,CONNECT,PATCH,TRACE
    method: string,
    // full request path
    path: string,
    // query string. May be an empty string if there were no query parameters.
    query-string: string,
    // map of request headers (string key, string value)
    header: header-map,
    body: list<u8>,
}

record http-response {
    // statusCode is a three-digit number, usually in the range 100-599 a value of 200 indicates success.
    status-code: u16,
    // Map of headers (string keys, list of values)
    header: header-map,
    // Body of response as a byte array. May be an empty array.
    body: list<u8>,
}

handle-request: function(req: http-request) -> expected<http-response, rpc-error>

The httpserver receiver has to export the receive method and implement the logic to parse that message as an HTTP request. Basically, it acts as a translation layer between the RPC layer and the actual contract it needs to call. The code for it is actually quite straightforward (and is annotated with comments below). Our code was written in Rust, but you could write it in any language that has wit-bindgen support

rust
use wasmbus_receiver::*;

// Import the httpserver contract so we can call it
wit_bindgen_rust::import!("../httpserver.wit");
// Export our implementation of the `receive` method
wit_bindgen_rust::export!("../wasmbus-receiver.wit");

const HANDLE_REQUEST_METHOD: &str = "HttpServer.HandleRequest";

// Some custom types for massaging data are elided here

#[derive(Default, Clone)]
pub struct WasmbusReceiver;

impl wasmbus_receiver::WasmbusReceiver for WasmbusReceiver {
    fn receive(msg: Message) -> Result<Payload, RpcError> {
        if msg.method != HANDLE_REQUEST_METHOD {
            return Err(RpcError::MethodNotHandled(format!(
                "Method {} is not supported by the httpserver contract",
                msg.method
            )));
        }
        // Parse the message body into an http request
        let req: HttpRequestInternal = serde_json::from_slice(&msg.arg)
            .map_err(|e| RpcError::Deser(format!("httpserver: {}", e)))?;
        // Data massaging
        let header: Vec<(&str, &str)> = req
            .header
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str()))
            .collect();

        // Call the `handle_request` method that will be provided by another module
        let resp: HttpResponseInternal = httpserver::handle_request(httpserver::HttpRequest {
            method: &req.method,
            path: &req.path,
            query_string: &req.query_string,
            header: &header,
            body: &req.body,
        })
        .map_err(httpserver_to_wasmbus_error)?
        .into();
        serde_json::to_vec(&resp).map_err(|e| RpcError::Ser(e.to_string()))
    }
}

The Business Logic​

This module is the actual part that contains the business logic that someone would be writing. This is the only user-provided code for someone writing their business logic to run on wasmCloud. All of the other modules described in this section would be provided by the interface writer or by wasmCloud directly.

This code also requires the use of two interfaces: httpserver (see the previous section) and keyvalue:

keyvalue.wit

use * from error-type

// Increment the value of the key by the given amount
increment: function(key: string, value: s32) -> expected<s32, rpc-error>

Please note that this is a stripped-down version of what the actual keyvalue contract would look like, to keep things simple. Now, we can move on to the actual code (annotated):

rust
use httpserver::*;

// Import the keyvalue contract so we can call it
wit_bindgen_rust::import!("../keyvalue.wit");

// Export our implementation of the httpserver contract
wit_bindgen_rust::export!("../httpserver.wit");

#[derive(Default, Clone)]
pub struct Httpserver;

impl httpserver::Httpserver for Httpserver {
    fn handle_request(req: HttpRequest) -> Result<HttpResponse, RpcError> {
        // make friendlier key
        let key = format!("counter:{}", req.path.replace('/', ":"));

        // bonus: use specified amount from query, or 1
        let amount: i32 = form_urlencoded::parse(req.query_string.as_bytes())
            .find(|(n, _)| n == "amount")
            .map(|(_, v)| v.parse::<i32>())
            .unwrap_or(Ok(1))
            .unwrap_or(1);

        // increment the value in kv and send response in json
        let (body, status_code) = match increment_counter(key, amount) {
            Ok(v) => (serde_json::json!({ "counter": v }).to_string(), 200),
            // if we caught an error, return it to client
            Err(e) => (
                serde_json::json!({ "error": format!("{:?}", e) }).to_string(),
                500,
            ),
        };
        let resp = HttpResponse {
            body: body.as_bytes().to_vec(),
            status_code,
            header: Vec::new(),
        };
        Ok(resp)
    }
}

fn increment_counter(key: String, value: i32) -> Result<i32, RpcError> {
    // Call the `increment` function that will be provided by another module
    keyvalue::increment(&key, value).map_err(map_wit_err)
}

You'll see that this code looks almost identical to the original kvcounter actor, except that there is zero wasmCloud-specific code needed!

The keyvalue "sender"​

As we mentioned above, there are two halves needed for each contract. This component exports the keyvalue contract described above and also requires one other interface:

wasmbus-sender.wit

use * from error-type
use * from wasmbus-common

send: function(msg: message, contract-name: string, link-name: option<string>) -> expected<payload, rpc-error>

As you can see, this has a single function called send that is used to send a message through the lattice. This does the exact reverse of the receiver, in that it takes a concrete type and turns it into a generic message that can be sent. The annotated code is below:

rust
use keyvalue::*;
use wasmbus_sender as wasmbus;

// Export our implementation of the keyvalue contract
wit_bindgen_rust::export!("../keyvalue.wit");
// Import the sender contract for us to call
wit_bindgen_rust::import!("../wasmbus-sender.wit");

// Custom request type elided

#[derive(Default, Clone)]
pub struct Keyvalue;

impl keyvalue::Keyvalue for Keyvalue {
    fn increment(key: String, value: i32) -> Result<i32, RpcError> {
        // Encode the data as our payload
        let payload = serde_json::to_vec(&IncrementRequest { key, value })
            .map_err(|e| RpcError::Ser(e.to_string()))?;

        // Call the `send` method provided by another module
        // NOTE: this code is not dealing with the link name yet just to keep it simple
        // We will figure out how we want this to work when implementing
        let resp = wasmbus::send(
            wasmbus::Message {
                method: "KeyValue.Increment",
                arg: &payload,
            },
            "wasmcloud:keyvalue",
            None,
        )
        .map_err(wasmbus_to_keyvalue_error)?;
        serde_json::from_slice(&resp).map_err(|e| RpcError::Deser(e.to_string()))
    }
}

The host sender​

Last, but not least, is the module that can send a message back to a host (so the host can send it on the lattice). For our purposes here, this just prints to stdout (which means we are using a function from the host just like we would for real), but when we do it for realsies, this will be calling a specific function the host will provide for us. This module only requires the wasmbus-sender.wit contract shown in the previous section. As for the code:

rust
use wasmbus_sender::*;

// Export our implementation for the `send` method
wit_bindgen_rust::export!("../wasmbus-sender.wit");

#[derive(Default, Clone)]
pub struct WasmbusSender;

impl wasmbus_sender::WasmbusSender for WasmbusSender {
    fn send(
        msg: Message,
        contract_name: String,
        link_name: Option<String>,
    ) -> Result<Payload, RpcError> {
        // Fake a host call (fd_write in this case)
        println!(
            "Linkname: {}, contract_name: {}, msg: {:#?}",
            link_name.unwrap_or_else(|| "default".to_string()),
            contract_name,
            msg
        );
        // Return the answer to everything
        Ok(serde_json::to_vec(&42).unwrap())
    }
}

Linking and running​

warning

NOTE: Right as we were preparing this blog post, the wasmlink command and tooling was removed from the wit-bindgen repo in favor of the most up to date component model code. This new component model tooling is going to be the future, but currently, there isn't really a replacement for wasmlink. So the section below is slightly out of date, but still shows that all of this will work with the new tooling in the future. As we actually implement this, we will release a new blog post that shows how the new tooling works

For our proof of concept, we used the wasmlink command. When we do this for real, we will use the underlying Rust linker library that wasmlink uses. To be honest, this tool is a little confusing to use, so hopefully we can enlighten you here. Before linking, we built all of the modules in the workspace by running cargo build --release. Once they were built, we ran the following command to link them together

bash
wasmlink ./target/wasm32-wasi/release/httpserver.wasm \
   -m keyvalue=./target/wasm32-wasi/release/keyvalue.wasm \
   -m httpserver=./target/wasm32-wasi/release/kvcounter_actor.wasm \
   -m wasmbus-sender=./target/wasm32-wasi/release/wasmbus_sender.wasm \
   -i wasmbus-sender=wasmbus-sender.wit \
   -i keyvalue=keyvalue.wit \
   -i httpserver=httpserver.wit \
   -i receiver=wasmbus-receiver.wit \
   -p wasmtime \
   -o compiled.wasm

Ok, so that is a pretty gnarly command. Let's break it down:

First off is the module name (./target/wasm32-wasi/release/httpserver.wasm). There is one very important detail here. This module should be the one you want to call (the one with the receive function), otherwise the export gets mangled and no longer shows up in the compiled file.

All of the -m flags specify the other modules to link in. They are specified in the form of MODULE_NAME=MODULE_PATH. The module name must match the name of the interface it is exporting. Those interfaces are specified with the -i flag with the form MODULE_NAME=WIT_PATH. The -o flag specifies the output path where the compiled module is written to.

Oh, and that -p flag? Pretty sure it doesn't matter based on what we found in the code, but it is a required flag. It does look like it may matter in the future though.

Whew...that was hard. Onward to the cool part – actually running the thing. We did this in code as it was needed to actually call everything properly. Let's look at the whole code sample (annotated):

rust
use wasmtime::{Config, Engine, Linker, Module, Store};
use wasmtime_wasi::WasiCtx;

// Import the wasmbus-receiver contract with the wasmtime helpers (note that this is a different
// crate than what we used above)
wit_bindgen_wasmtime::import!(
    "/Users/oftaylor/Documents/code/examples/actor/kvcounter/wasmbus-receiver.wit"
);

// Elided an http request type here

// A custom struct for storing data in the wasmtime engine
struct StoreData {
    wasi: WasiCtx,
    receiver: wasmbus_receiver::WasmbusReceiverData,
}

fn main() {
    let mut config = Config::default();
    // Enable the experimental module linking and multimemory proposals. These are required to make things work
    config.wasm_module_linking(true);
    config.wasm_multi_memory(true);
    let engine = Engine::new(&config).unwrap();

    let mut linker: Linker<StoreData> = Linker::new(&engine);
    // Add all the wasi stuff to the linker
    wasmtime_wasi::add_to_linker(&mut linker, |ctx| &mut ctx.wasi)
        .expect("Unable to add to wasi things to linker");

    let wasi = wasmtime_wasi::WasiCtxBuilder::new()
        .inherit_stdio()
        .inherit_args()
        .expect("Unable to inherit args")
        .build();

    let mut store = Store::new(
        &engine,
        StoreData {
            wasi,
            receiver: wasmbus_receiver::WasmbusReceiverData {},
        },
    );

    // Load the compiled wasm module we built above
    let receiver_module = Module::from_file(
        &engine,
        "/code/examples/actor/kvcounter/compiled.wasm",
    )
    .unwrap();

    // Use the instantiate helper from wit-bindgen
    let (server, _instance) = wasmbus_receiver::WasmbusReceiver::instantiate(
        &mut store,
        &receiver_module,
        &mut linker,
        |ctx| &mut ctx.receiver,
    )
    .unwrap();

    let req = HttpRequestInternal {
        method: "GET",
        path: "/",
        query_string: "",
        header: vec![("HOST", "foobar")],
        body: &[],
    };
    // Call the receive method exported by our module
    let resp = server
        .receive(
            &mut store,
            wasmbus_receiver::Message {
                method: "HttpServer.HandleRequest",
                arg: &serde_json::to_vec(&req).expect("Should serialize"),
            },
        )
        .expect("Shouldn't get a trap")
        .expect("Unable to send to actor");

    println!("body: {}", String::from_utf8_lossy(&resp));
}

When we ran this, we could see the output:

Linkname: default, contract_name: wasmcloud:keyvalue, msg: Message {
    method: "KeyValue.Increment",
    arg: [
        123,
        34,
        107,
        101,
        ...
    ],
}
body: {"status_code":200,"header":[],"body":[123,34,99,111,117,110,116,101,114,34,58,52,50,125]}

This means we got all the way down to the send method and returned data all the way up the stack as the expected HTTP response!

What did we learn?​

Benefits​

  • We won't need to use our bespoke Smithy + code generation any more
  • No more bespoke wasmbus libraries per language. Modules can even be loaded by providers to properly translate a message from the lattice
  • No wasmCloud-specific code when you write your actors. In fact, if our contract is the same as those used by other platforms, they could even be interchangeable!
  • Easily pluggable and patchable wasmCloud specific code. If there is a bug fix we have to the underlying RPC protocol, we can hot patch all running actors with no user interaction

Rough edges​

To be clear, it isn't all sunshine and rainbows yet. These are a few of the rough edges we encountered and how they impacted us

  • No dynamic linking yet. This means we have to manually pull everything down and link it before we can run it. Not ideal, but we are able to do it through code.
  • Linking everything means you must include both the module and the interface file when distributing things, which means you have to build tooling around building things like bindles
  • Even when you have reused types (like our rpc-error above in the wit files), each interface technically has a different type in strongly-typed languages. This requires conversion between the identical types imported from different interfaces. Obviously things like Rust macros can be use to make this a little less clunky, but it is a bit of a chore

Where do we go from here?​

Now we move into the future. To be absolutely clear, YOU CANNOT yet do this inside of wasmCloud, but this proof of concept proved that we can use the component model to greatly improve the experience of writing actors in wasmCloud and achieve our goal of being in line with community standards. In order to make this all work, it will take a major refactor of the underlying code we use to run actors as well as some refactors to our RPC layer. This will obviously be a breaking change so we will need to clearly communicate when the work is going to land so as to not disturb too many of our current users.

We will also need to rely more heavily on Bindle and eventually on the forthcoming component registry work from the Bytecode Alliance. These tools are designed specifically to account for assembling various parts of a final application (like the various interfaces and different modules). We already have experimental support for bindles in wasmCloud, but they have to be hand rolled rather than being automatically created. There also needs to be a place from which you can fetch the necessary interfaces for use in building. All of these elements of developer experience are important to have before we roll this out.

So, stay tuned! We are planning on a follow up blog post to this one once we actually roll out the support in wasmCloud

Special Thanks​

We wanted to give a shout out and thanks to Radu Matei for his help as we figured out some of the intricacies of the component model, as well as his previous work and blog posts in this area. That work gave us a great starting place for what we are building here.