Capsule: the WASM runners 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:
The Capsule Host SDK (HDK): An SDK for developing host applications in Go using Wazero (thus, Capsule Apps).
The Capsule Module SDK (MDK): An SDK for developing WASM modules in TinyGo, usable by applications developed with the HDK.
And finally, the Capsule project is a project offering multiple Capsule Apps developed with the HDK.
🤚 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 wasHandle(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" sampleMemory 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
The former Capsule project: Capsule, my personal wasm multi-tools knife
Subscribe to my newsletter
Read articles from Philippe Charrière directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by