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!