Skip to main content

Getting started with waPC and WebAssembly

· 8 min read
Jarrod Overson

Just learning about waPC? Make sure you look at Building WebAssembly platforms with waPC for an introduction.

This tutorial gets you hands-on experience building a waPC guest using Rust and compiling down to WebAssembly. For the guest language you can switch to Go, AssemblyScript, or Zig with only light modifications. When we're done we'll run our guest in the browser with the waPC JavaScript host. Head over to Building a waPC Host in Node.js after to build your own host from scratch.

Installing the wapc CLI

The waPC CLI is a Go program you can install via the command:

$ curl -fsSL https://raw.githubusercontent.com/wapc/cli/master/install/install.sh | /bin/bash

Confirm your installation was successful by running wapc --help

Creating your waPC guest project

Use the wapc new command followed by your desired language and the project name. I'm writing in rust and I chose the name wapc-tutorial

$ wapc new rust wapc-tutorial
Creating project directory ./wapc-tutorial
Please enter the project description: Just a basic tutorial

Please enter the version (Default is 0.0.1): 0.0.1

New to Rust?

Use rustup to keep Rust and tools installed and up-to-date. Install it via the command:

$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

You'll need to add the WebAssembly target so that you can compile down to wasm:

$ rustup target add wasm32-unknown-unknown

These commands will also install cargo, Rust's package manager.

Editing your schema.apexlang

The default schema generated by wapc is pretty slim:

namespace "greeting"

interface {
sayHello(name: string): string
}

The namespace helps describe a scope for objects in Apex but note that Apex does nothing on its own. The wapc CLI's code generation uses Apex but the templates are configurable. I point this out to highlight that namespace is a convention and the implementation is undefined. In terms of this tutorial it means that namespace has no effect. WasmCloud — one of the first major consumers of waPC — uses [project name]:[module name] for its namespaces so I'll do the same and use tutorial but you're free to do what you want.

The interface block describes the operations we expose. The default Apex template describes an operation named sayHello that takes a string for the name parameter and returns a string as its output. Let's change that method to toLowercase and add another for toUppercase with the same parameter name. This is what we have so far:

namespace "tutorial"

interface {
toUppercase(name: string): string
toLowercase(name: string): string
}

Generate your code!

Run make codegen to automatically run wapc generate codegen.yaml. You'll eventually want to familiarize yourself with the internals of codegen.yaml but ignore it for now.

$ make codegen
wapc generate codegen.yaml
Generating src/generated.rs...
Generating src/lib.rs...
Formatting src/generated.rs...
Formatting src/lib.rs...

You'll end up with two new Rust files, src/generated.rs and src/lib.rs.

generated.rs is worth looking at but diving into it is another post. Open up lib.rs which should look like this:

mod generated;
extern crate wapc_guest as guest;
pub use generated::*;
use guest::prelude::*;

#[no_mangle]
pub fn wapc_init() {
Handlers::register_to_uppercase(to_uppercase);
Handlers::register_to_lowercase(to_lowercase);
}

fn to_uppercase(_name: String) -> HandlerResult<String> {
Ok("".to_string()) // TODO: Provide implementation.
}

fn to_lowercase(_name: String) -> HandlerResult<String> {
Ok("".to_string()) // TODO: Provide implementation.
}

The first two lines include our generated module and the wapc_guest Rust crate. The following two import everything from our generated module as well as wapc_guest::prelude:

mod generated;
extern crate wapc_guest as guest;
pub use generated::*;
use guest::prelude::*;

generated.rs exports data types generated from your Apex, register_* functions, and utility functions like deserialize and serialize.

wapc_guest::prelude exports useful functions like console_log and data types like HandlerResult and CallResult.

Next we have the public wapc_init function which is the function a waPC host executes when initializing a guest.

#[no_mangle]
pub fn wapc_init() {
Handlers::register_to_uppercase(to_uppercase);
Handlers::register_to_lowercase(to_lowercase);
}

This needs to be exposed as-is so we use #[no_mangle] to prevent the compiler from changing the name and causing initialization problems. Inside the wapc_init function we have two generated lines for each of our exposed operations. We don't need to change these but just note that wapc generates code that suits the style of the destination language. It changed toUppercase to to_uppercase to matches Rust's coding guidelines.

The two helper functions passed to the register_* functions are stubs where you can add your custom logic.

fn to_uppercase(_name: String) -> HandlerResult<String> {
Ok("".to_string()) // TODO: Provide implementation.
}

fn to_lowercase(_name: String) -> HandlerResult<String> {
Ok("".to_string()) // TODO: Provide implementation.
}

Here we have two functions that both take a String and return a HandlerResult<String>. If you are not familiar with Rust, Result types capture success and errors into a single return value. This is idiomatic Rust, not waPC. Guest implementations in other languages will match the style and practices of those languages.

To flesh out our uppercase and lowercase functions we just code as normal. If you're following along with this simple tutorial, we're only a couple of methods away from making these functions do as they're expected:

fn to_uppercase(name: String) -> HandlerResult<String> {
Ok(name.to_ascii_uppercase())
}

fn to_lowercase(name: String) -> HandlerResult<String> {
Ok(name.to_ascii_lowercase())
}

That's it! The generated waPC code in generated.rs does all the serialization and type conversion for you so the code you write is just normal code. If you have previous Rust experience (or the language you chose), take a look at the generated code to see how it works. It's not magic, it just feels that way.

Compiling to WebAssembly

The project wapc new creates includes a Makefile that has the basic tasks you need to go from start to finish. To compile our code to a wasm binary, run make. This runs three subtask: make deps which does nothing for this project by default, make codegen which is what we already ran to generate our code, and make build which runs cargo build --target wasm32-unknown-unknown --release and copies the resulting .wasm file to a build/ directory in our project root.

If all goes as planned, then you should have a wasm file in your build/ directory. In my case it's build/wapc_tutorial.wasm. Yours will be named according to what you named your project when you ran wapc new or later edited in your Cargo.toml file.

Whoa that size

If you're using the default Rust setup, take notice that your wasm file is not very lean. On my machine the wasm file clocks in at a whopping 1.7 megabytes for these two measly string manipulation functions. Keep in mind WaPC started on the server and users are only just starting to bring it to the web. A large chunk of this size can be optimized away and work is already underway to trim this down, but the fact remains that WebAssembly fundamentally changes the game. Many existing libraries and languages start at a disadvantage. Developers didn't need to worry about build size much unless they targeted embedded platforms. Compare the Rust build with WebAssembly-first languages like AssemblyScript or TinyGo which come in at 17k and 11k, respectively. While both are smaller by orders of magnitude, it's a tradeoff. Despite the marketing TinyGo is not actually Go and AssemblyScript is not actually TypeScript. You can't benefit from the developer and open source community as much as you can with Rust. There is no one-size-fits-all WebAssembly language yet.

Some day we'll probably see a single language dominate the WebAssembly landscape but right now you have to optimize for your destination. WaPC really helps out here because you can jump in and out of other languages while retaining the same workflow. The API to every waPC guest and host is identical and Apex is universal. You can use the wapc CLI to generate a lot of the platform-specific code and still only work on your business logic.

Running your wasm file

Head on over to our waPC loader and load your wasm file from disk. Fill in your operation (toUppercase or toLowercase if you're following along) and the input data (e.g. {"name": "Samuel Clemens"}) and press Run. Hopefully all goes well and you'll have the result show up in the Result: box along with some status logging down below.

You should see something similar to this:

If something did go wrong then hopefully the error messages will help. If not, then drop us a line and we'll figure out what went wrong.

Writing your own host

WaPC guests are only half the equation, the next step is making your own host that leverages the functionality. Head over to Building a waPC Host in Node.js when you're ready to get started!