Capsule: the WASM runners project

My 💜 WebAssembly side project

Capsule project's origins

About a year ago, to consolidate my learning of Go and my knowledge of WebAssembly, I started a project named 'Capsule' (I started to release a "usable" version in August last year). Capsule was a WebAssembly function launcher (or runner). It means that, with only one application, you could:

  • From your terminal, execute a function of a WASM module (the "CLI mode")

  • Serving a WASM module through HTTP (the "HTTP mode")

  • Use a WASM module as a NATS subscriber or an MQTT subscriber

And the Capsule application offered "host functions" to add functionalities to WASM modules (such as writing to a file, making an HTTP request, etc.).

The host functions are functions outside WebAssembly and are passed to WASM modules as imports

From the beginning, the Capsule application has been developed in Go using the Wazero WASM runtime (Wazero is the only zero-dependency WebAssembly runtime written in Go.). And the WASM modules were built with TinyGo, which can compile Go code to the WASM format following the WASI specification.

Capsule quickly became a cumbersome beast (or a tiny monster) to manage, with too much code. I injected complexity as I experimented, making it difficult to maintain and evolve."

A few weeks ago, I decided to do some gardening in the code of Capsule to improve it, make the code more readable, and also make the project more scalable. In the end, this led me to rethink the project's structure completely and ultimately split it into multiple projects.

What is the new Capsule project?

I have divided the old project into three new projects:

🤚 In addition to improved readability and maintainability, I achieved a significant performance boost (up to 10 times faster).

So, a Capsule application is a runner (or launcher) of wasm functions. Right now, two Capsule Apps are available:

  • Capsule CLI to execute WASM modules in a terminal.

  • Capsule HTTP to serve the functions through HTTP (it was the initial objective of the Capsule project).

It is essential to understand that thanks to the HDK (and the MDK), you can develop your own Capsule Apps (and on my side, it allows me to evolve Capsule HTTP at my own pace and according to my ideas and to experiment more easily with new Capsule Apps).

Now it's time to see how all of this works. Let's start with the Capsule CLI.

Capsule CLI

Prerequisites

You need to install GoLang and TinyGo.

Look at the appendix paragraph at the end of this post, I explain how to install Go and TinyGo on Linux.

Install Capsule CLI

The installation of the Capsule CLI is pretty simple (choose the appropriate OS and architecture):

VERSION="v0.3.6" OS="linux" ARCH="arm64"
wget -O capsule https://github.com/bots-garden/capsule/releases/download/${VERSION}/capsule-${VERSION}-${OS}-${ARCH}
chmod +x capsule
sudo mv capsule /usr/bin/capsule
capsule --version

# v0.3.6 🫐 [blueberries]

There are 4 distributions of the Capsule CLI:

  • capsule-v0.3.6-darwin-amd64

  • capsule-v0.3.6-darwin-arm64

  • capsule-v0.3.6-linux-amd64

  • capsule-v0.3.6-linux-arm64

Now, it's time to create the first WASM module.

Create a Capsule WASM module (for the Capsule CLI)

Project module setup

Type the below commands to create a new WASM module project:

mkdir hello-world
cd hello-world
go mod init hello-world
# Install the Capsule MDK dependencies:
go get github.com/bots-garden/capsule-module-sdk

Module source code

Create a main.go file (into the hello-world directory):

package main

import (
    capsule "github.com/bots-garden/capsule-module-sdk"
)

func main() {
    capsule.SetHandle(Handle)
}

// Handle function
func Handle(params []byte) ([]byte, error) {

    // Display the content of `params`
    capsule.Print("📝 module parameter(s): " + string(params))

    return []byte("👋 Hello " + string(params)), nil
}
  • capsule.SetHandle(Handle) defines the function to call at the start. The Capsule MDK provides several kinds of "handlers".

  • capsule.Print is a host function of the MDK & HDK to display a message. See the Capsule Host Functions paragraph (in the appendix) to get the list of the available functions.

Build the WASM module

tinygo build -o hello-world.wasm -scheduler=none --no-debug -target wasi ./main.go

Call the WASM module with the Capsule CLI

To execute the WASM module with the Capsule CLI, use the --wasm flag to give the path of the .wasm file and the --params flag to pass the parameters

capsule --wasm=hello-world.wasm --params="Bob Morane"

Output:

📝 module parameter(s): Bob Morane
👋 Hello Bob Morane

The other Capsule App is "Capsule HTTP". This time, we are going to create a small microservice with a WASM module served by Capsule HTTP.

Capsule HTTP server

Install Capsule HTTP

To install the Capsule HTTP server, use the below commands (choose the appropriate OS and architecture):

VERSION="v0.3.6" OS="linux" ARCH="arm64"
wget -O capsule-http https://github.com/bots-garden/capsule/releases/download/${VERSION}/capsule-http-${VERSION}-${OS}-${ARCH}
chmod +x capsule-http
sudo mv capsule-http /usr/bin/capsule-http
capsule-http --version

# v0.3.6 🫐 [blueberries]

There are 4 distributions of the Capsule CLI:

  • capsule-http-v0.3.6-darwin-amd64

  • capsule-http-v0.3.6-darwin-arm64

  • capsule-http-v0.3.6-linux-amd64

  • capsule-http-v0.3.6-linux-arm64

Creating a WASM module for the Capsule HTTP server is as simple as for the CapsuleCLI.

Create a Capsule WASM module (for the Capsule HTTP server)

Project module setup

First, create a new project:

mkdir hello-you
cd hello-you
go mod init hello-you
# Install the Capsule MDK dependencies:
go get github.com/bots-garden/capsule-module-sdk

Module source code

Create a main.go file (into the hello-world directory):

// Package main
package main

import (
    "strconv"

    "github.com/bots-garden/capsule-module-sdk"
    "github.com/valyala/fastjson"
)

func main() {
    capsule.SetHandleHTTP(Handle)
}

// Handle function 
func Handle(param capsule.HTTPRequest) (capsule.HTTPResponse, error) {

    capsule.Print("📝: " + param.Body)
    capsule.Print("🔠: " + param.Method)
    capsule.Print("🌍: " + param.URI)
    capsule.Print("👒: " + param.Headers)

    var p fastjson.Parser
    jsonBody, err := p.Parse(param.Body)
    if err != nil {
        capsule.Log(err.Error())
    }
    message := string(jsonBody.GetStringBytes("name")) + " " + strconv.Itoa(jsonBody.GetInt("age"))

    response := capsule.HTTPResponse{
        JSONBody: `{"message": "`+message+`"}`,
        Headers: `{"Content-Type": "application/json; charset=utf-8"}`,
        StatusCode: 200,
    }

    return response, nil
}
  • capsule.SetHandleHTTP(Handle) defines the function to call at the start. The signature of the handler for the CLI was Handle(params []byte) ([]byte, error). For Capsule HTTP, it is a little bit more "sophisticated": Handle(param capsule.HTTPRequest) (capsule.HTTPResponse, error)

  • fastjson is a Golang framework that works very well with TinyGo

Build the WASM module

tinygo build -o hello-you.wasm -scheduler=none --no-debug -target wasi ./main.go

Serve the WASM module with the Capsule HTTP server

To serve the WASM module with the Capsule HTTP server, use the --wasm flag to give the path of the .wasm file and the --httpPort flag to define the HTTP port.

capsule-http --wasm=hello-you.wasm --httpPort=8080

Call the WASM service

Now you can call the service like this:

curl -X POST http://localhost:8080 \
    -H 'Content-Type: application/json; charset=utf-8' \
    -d '{"name":"Bob Morane","age":42}'

Output:

{"message":"Bob Morane 42"}

Output on the server side:

📝: {"name":"Bob Morane","age":42}
🔠: POST
🌍: http://localhost:8080/
👒: "Host":"localhost:8080","Content-Length":"30","Content-Type":"application/json; charset=utf-8","User-Agent":"curl/7.81.0","Accept":"*/*"

As I said at the beginning of this post, you can now create a Capsule App with the HDK (Host Development Kit). Let's see how to do this with a simple HTTP server to serve a WASM module

Create a Capsule application

We will serve through HTTP the first created WASM module at the beginning of this post: hello-world.wasm

Project application setup

We need to create a new project:

mkdir cracker
cd cracker
go mod init cracker
# Install the Capsule HDK dependencies:
go get github.com/bots-garden/capsule-host-sdk

Source code of the host application

package main

import (
    "context"
    "fmt"
    "io/ioutil"
    "log"
    "net/http"
    "os"

    "github.com/bots-garden/capsule-host-sdk"
    "github.com/bots-garden/capsule-host-sdk/helpers"

    "github.com/tetratelabs/wazero"
)

var wasmFile []byte
var runtime wazero.Runtime
var ctx context.Context

func main() {

    ctx = context.Background()

    // Create a new WebAssembly Runtime.
    runtime = capsule.GetRuntime(ctx)

    // Get the builder and load the default host functions
    builder := capsule.GetBuilder(runtime)

    // Instantiate builder and default host functions
    _, err := builder.Instantiate(ctx)
    if err != nil {
        log.Println(err)
        os.Exit(1)
    }

    // This closes everything this Runtime created.
    defer runtime.Close(ctx)

    // Load the WebAssembly module
    args := os.Args[1:]
    wasmFilePath := args[0]
    httpPort := args[1]

    wasmFile, err = helpers.LoadWasmFile(wasmFilePath)
    if err != nil {
        log.Println(err)
        os.Exit(1)
    }

    // Registering the http handler: "callWASMFunction"
    // "callWASMFunction" will be triggered at every HTTP request
    http.HandleFunc("/", callWASMFunction)

    fmt.Println("Cracker is listening on", httpPort)

    // Listening on port 8080
    http.ListenAndServe(":"+httpPort, nil)
}

// A handler for "/" route
func callWASMFunction(w http.ResponseWriter, req *http.Request) {

    // Instanciate the Capsule WASM module
    mod, err := runtime.Instantiate(ctx, wasmFile)
    if err != nil {
        fmt.Fprintf(w, err.Error()+"\n")
    }

    // Get the reference to the WebAssembly function: "callHandle"
    // "callHandle" is exported by the Capsule WASM module
    // "callHandle" is called at the start of the module
    // Remember (on the WASM side):
    // func main() {
    //     capsule.SetHandle(Handle)
    // }
    handleFunction := capsule.GetHandle(mod)

    // Read the bosy of the request
    body, err := ioutil.ReadAll(req.Body)
    if err != nil {
        fmt.Fprintf(w, err.Error()+"\n")
    }

    // Execute "callHandle" with the body as parameter
    result, err := capsule.CallHandleFunction(ctx, mod, handleFunction, body)

    // Return the result or the error to the HTTP client
    if err != nil {
        fmt.Fprintf(w, err.Error()+"\n")
    } else {
        fmt.Fprintf(w, string(result)+"\n")
    }
}

Buil, Run, Test

To build the project:

go build

To run the HTTP server, use the below commands:

./cracker ../hello-world/hello-world.wasm  8080

To test the service:

curl -X POST http://localhost:8080 \
    -H 'Content-Type: text/plain; charset=utf-8' \
    -d "Bob Morane 🥰"

Output:

👋 Hello Bob Morane 🥰

Output on the server side:

📝 module parameter(s): Bob Morane 🥰

So, that's all for today. The next time, I will explain how to add a host function to a Capsule App without changing the HDK or the MDK.

All the examples are available here: https://github.com/bots-garden/capsule-sandbox

Appendix

Install Go and TinyGo (Linux)

I'm working with Ubuntu (on an arm computer).

GOLANG_VERSION="1.20"
GOLANG_OS="linux"
GOLANG_ARCH="arm64"
TINYGO_VERSION="0.27.0"
TINYGO_ARCH="arm64"

# -----------------------
# Install GoLang
# -----------------------
wget https://go.dev/dl/go${GOLANG_VERSION}.${GOLANG_OS}-${GOLANG_ARCH}.tar.gz

sudo rm -rf /usr/local/go 
sudo tar -C /usr/local -xzf go${GOLANG_VERSION}.${GOLANG_OS}-${GOLANG_ARCH}.tar.gz

echo "" >> ${HOME}/.bashrc
echo "export GOLANG_HOME=\"/usr/local/go\"" >> ${HOME}/.bashrc
echo "export PATH=\"\$GOLANG_HOME/bin:\$PATH\"" >> ${HOME}/.bashrc

source ${HOME}/.bashrc

rm go${GOLANG_VERSION}.${GOLANG_OS}-${GOLANG_ARCH}.tar.gz               

# -----------------------
# Install TinyGo
# -----------------------
wget https://github.com/tinygo-org/tinygo/releases/download/v${TINYGO_VERSION}/tinygo_${TINYGO_VERSION}_${TINYGO_ARCH}.deb
sudo dpkg -i tinygo_${TINYGO_VERSION}_${TINYGO_ARCH}.deb
rm tinygo_${TINYGO_VERSION}_${TINYGO_ARCH}.deb

Capsule Host functions

This is the list of the available host functions:

  • Print a message: Print(message string), usage: capsule.Print("👋 Hello Worls 🌍")

  • Log a message: Log(message string), usage: capsule.Log("😡 something wrong")

  • Get the value of an environment variable: GetEnv(variableName string) string, usage: capsule.GetEnv("MESSAGE")

  • Read a text file: ReadFile(filePath string) ([]byte, error), usage: data, err := capsule.ReadFile("./hello.txt")

  • Write content to a text file: WriteFile(filePath string, content []byte) error, usage: err := capsule.WriteFile("./hello.txt", []byte("👋 Hello World! 🌍"))

  • Make an HTTP request: HTTP(request HTTPRequest) (HTTPResponse, error), usage: respJSON, err := capsule.HTTP(capsule.HTTPRequest{}), see the "hey-people" sample

  • Memory Cache: see the "mem-db" sample

    • CacheSet(key string, value []byte) []byte

    • CacheGet(key string) ([]byte, error)

    • CacheDel(key string) []byte

    • CacheKeys(filter string) ([]string, error) (right now, you can only use this filter: *)

  • Redis Cache: see the "redis-db" sample

    • RedisSet(key string, value []byte) ([]byte, error)

    • RedisGet(key string) ([]byte, error)

    • RedisDel(key string) ([]byte, error)

    • RedisKeys(filter string) ([]string, error)

  • More host functions are to come in the near future.

  • It's already possible to create your own host functions if you develop a Capsule Application (I need to work on the documentation and samples before writing something about this topic).

Some reading