Writing a WebAssembly Service in TinyGo for Wagi and Spin

Small is beautiful. In this post we create a tiny Spin service using the TinyGo compiler for the Go programming language.

On could fill a library with the “Hello World” examples in circulation today. When I set out to write this post, I decided to base it on a production service instead of a contrived example. In this blog post, we’ll look at a server for favicon.ico files. As simple as this little bit of code is, we actually run it at Fermyon.com, and have since the day we launched our website.

The Environment: Spin and Wagi

Spin is Fermyon’s WebAssembly framework. A wide variety of programming languages can be used to create Spin applications. The core requirement is that the language must be compiled to WebAssembly plus WASI. From there, Spin uses the Wagi protocol to exchange information between the Spin server and the WebAssembly guest code.

Wagi is a simple specification for how HTTP requests and responses can be encoded using WASI. Inspired by the old Common Gateway Interface (CGI) definition for web handling, Wagi (which stands for WebAssembly Gateway Interface) allows developers to write minimal applications in which HTTP information is conveyed via environment variables, and standard output (STDOUT) is sent directly back to the web browser.

So for our little program, we will need no HTTP or server framework, no database, or even a network library at all. Everything will be done via file I/O.

Again, the program service discussed here in this post is the one we run in production on Fermyon.com. While we’re always evolving, at the time of this writing, Fermyon.com is powered by Hippo, Spin, Bindle, and the Nomad scheduler.

The Language: Go and TinyGo

The list of programming languages that compile to WebAssembly is growing. And one of our perennial favorites is Go. Support for WebAssembly in Google’s version of Go is a little behind. But the TinyGo project has filled in with an excellent implementation that, as the name implies, compiles size-optimized Go binaries. While TinyGo supports dozens of target platforms, one of their primary compile targets is WebAssembly with the WASI extensions.

When we set out to write the wagi-favicon server, we needed to get it done quickly. Go has great I/O libraries. So we felt it would support our minimalist approach to writing this service.

One word of warning, though: At the time of this writing, the smallest size for writing a Go program in Wasm seems to be about 300k. Some languages like Grain and C can be compiled to even smaller sizes. So if your primary concern is binary size, you might start there. But for us, 300-400k is well within our tolerance for an application size. Wagi on our cloud provider doesn’t even blink when executing such small modules.

For this project to work, you must have both Go and the TinyGo compiler installed on your system.

The Problem: Serving favicon.ico Files on Wagi

We were only three days from launching the Bartholomew-based Fermyon.com website when we realized we had a problem: We were seeing 404 Not Found errors in our logs. We were not serving the industry-standard favicon.ico file that is used to put a little icon in the browser’s location bar.

Wagi and the rest of the Fermyon Platform are built around the idea that we should think (and code) in terms of microservices that each handle discrete tasks. Rather than add more code to Bartholomew to serve static .ico files, if we are truly thinking in terms of microservices, we should write a service that handles this one discrete problem. After all, many other websites will need the same functionality, and there’s no reason they should have to install an entire CMS system just to serve out a single static image file.

So we decided to create a simple server that handles requests for favicon.ico, serving out our icon for each request.

The Code: A Web Service in Under 50 Lines

Our service is named spin-favicon. It consists of a single Go file, a directory to store our static image file, and a few supporting things like the Makefile and LICENSE. To test it out, we also included a spin.toml, which is a simple configuration file that Spin understands.

Our code is under 50 lines. Here it is in its entirety:

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"

	spin "github.com/fermyon/spin/sdk/go/http"
)

func main() {
	spin.HandleRequest(handle)
}

func handle(w http.ResponseWriter, r *http.Request) {
	filename := os.Getenv("FAVICON_PATH")
	if filename == "" {
		filename = "/favicon.ico"
	}
	mediaType := "image/vnd.microsoft.icon"

	// Open the file at /favicon and print it.
	var input, err = os.Open(filename)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error opening file %s: %s\n", filename, err)
		send404(w)
		return
	}

	w.Header().Add("content-type", mediaType)
	if _, e := io.Copy(w, input); e != nil {
		fmt.Fprintf(os.Stderr, "Error writing file %s: %s\n", filename, e)
	}
}

func send404(w http.ResponseWriter) {
	w.WriteHeader(http.StatusNotFound)
	w.Header().Add("content-type", "text/plain")
	w.Write([]byte("Not Found"))
}

That is the entire service. In a moment we will go through the code in more detail, but first we need to make sure we import the right helper library. The go.mod file for this code looks like this:

module github.com/fermyon/spin-favicon

go 1.17

require github.com/fermyon/spin/sdk/go v0.1.0

(Note that the Spin SDK is a nice wrapper around the Wagi protocol.)

Using main() to Register an HTTP Handler

Go programmers are familiar with the fact that a Go program starts by invoking the main() function. Spin programs are no different. In this case, our main() function has a very simple job: Tell Spin what function to execute when a request comes in:

func main() {
	spin.HandleRequest(handle)
}

That tells Spin to use a function named handle(). Let’s look at that function.

Loading an Environment Variable

The first few lines of the handle() function load an environment variable. In Spin, configuration directives can be passed as environment variables. This is a useful way to allow an operations team to set or override a values. In our case, our production instance of spin-favicon stores files in a different location than the default, so the environment variable allows the default favicon path to be overridden.

The environment variable we care about is FAVICON_PATH. If it is set, we want to use it as the path to our favicon.ico file. If it is not set, then we fall back to a default path of /favicon.ico.

filename := os.Getenv("FAVICON_PATH")
if filename == "" {
    filename = "/favicon.ico"
}
mediaType := "image/vnd.microsoft.icon"

Note that we also set the mediaType (a.k.a content-type or MIME type) here. (The reason for this is a little odd: We found conflicting documentation about what the correct media type was. In the end, this one seems correct. But we wanted to make it easy to locate in case it needed to be changed later.)

Now that we know what file to look for, we can open it.

File I/O and Early Error Handling

We now have the full name of the file that holds our favicon. The filename variable has either been overridden or is /favicon.ico. Now it’s time to load the file.

// Open the file at /favicon and print it.
var input, err = os.Open(filename)
if err != nil {
	fmt.Fprintf(os.Stderr, "Error opening file %s: %s\n", filename, err)
	send404(w)
	return
}

Opening a file is an occasion for an error. So we do this as soon as possible. If it opens successfully, then input is a file handle to our data. But if there is an error, we detect it before we’ve sent any other output to the client. If opening fails, we can log an error and exit.

Sending an error response

Taking a quick detour from handle(), the send404() function looks like this:

func send404(w http.ResponseWriter) {
	w.WriteHeader(http.StatusNotFound)
	w.Header().Add("content-type", "text/plain")
	w.Write([]byte("Not Found"))
}

This convenience function takes the HTTP response object and sends the following three pieces of information:

  • The status code is set to StatusNotFound (a.k.a. 404 Not Found)
  • The content type is set to text/plain
  • The body is set to the plain text string Not Found

Once the w.Write() function is called, the data is sent back to the client.

That’s all there is to error handling in Spin.

It would also have been appropriate to send a 500 Internal Server Error message here. We felt that 404 indicated that the server currently did not have a favicon available, and that did indeed capture our intent.

Finally, after sending the body, we return early to stop processing.

That’s the error case. But if there was no error, then input now has a file. Let’s see how the server handles sending that data back to the browser.

Sending the File Content

Sending the file content is the easiest part of this job. We only need to do two things:

  • Tell the client what type of content we are sending
  • Send the content
w.Header().Add("content-type", mediaType)
if _, e := io.Copy(w, input); e != nil {
	fmt.Fprintf(os.Stderr, "Error writing file %s: %s\n", filename, e)
}

Earlier, we create the mediaType variable to store the correct media type (image/vnd.microsoft.icon). We use w.Header().Add() to add the content type header. That’s step 1. Now all we need to do is copy our Favicon image to the response. Using io.Copy(), we send the data straight from the file (input) to the response (w).

But what happens if our io.Copy() step fails? For example, the input file may become unavailable or the remote client may close its connection, which closes os.Stdout.

Here we are in a bit of a conundrum, because we have definitely already sent the headers. So we can’t pivot and send a 404 or a 500. And there is a good chance that the reason for the failure is that os.Stdout is disconnected. Besides that, we’re sending image data. It doesn’t make sense to try to send a text error message if our copy fails midway through. So we’re left with a dull way of handling the error: Log it.

fmt.Fprintf(os.Stderr, "Error writing file %s: %s\n", filename, e)

Logging to os.Stderr will result in Spin writing this message to the application’s specific log file. Now all we need to do is compile.

Compiling with TinyGo

With a normal Go program, we’d compile with go build. However, since Go does not currently compile to Wasm32-WASI, we need to use the TinyGo compiler. Fortunately, it behaves the same as the regular Go compiler.

When you’re ready to compile, use TinyGo to build a WebAssembly + WASI binary:

$ tinygo build -o favicon.wasm -target wasi main.go

The above will create a file named favicon.wasm. This is the binary that Spin will execute.

Setting -target wasi is the way we tell TinyGo to build for Wasm32-WASI. Do not set it to wasm or it will compile for the browser.

Running The Server

To test locally, we need the Spin binary. Install whatever is the latest version.

Next we need to define a spin.toml file to tell Spin how to load our new favicon service:

spin_version = "1"
authors = ["Fermyon Engineering <engineering@fermyon.com>"]
description = "A Favicon server written in (Tiny)Go"
name = "spin-favicon"
trigger = { type = "http", base = "/" }
version = "1.0.0"

[[component]]
id = "favicon"
source = "favicon.wasm"
files = ["favicon.ico"]
[component.trigger]
route = "/favicon.ico"
executor = { type = "wagi" }

The first section (before [[component]]), tells Spin about the application. The most important line is this one:

trigger = { type = "http", base = "/" }

This effectively tells Spin to start an HTTP server listening at the path /.

Next, the [[component]] section tells Spin all about our module.

  • id is a string, unique to this file, that names our component
  • source is the path to the .wasm file that Spin should run (favicon.wasm in our case)
  • files is a list of files that should be copied out of our local filesystem and into Spin’s pseudo-filesystem. In our case, we copy a list with only one member: ["favicon.ico"]
  • The [component.trigger] section defines how Spin should map a request to an invocation of the .wasm file
    • route says that any request to /favicon.ico should be handled by this .wasm module.
    • executor tells Spin that the module should be executed as a Wagi module. (Spin can execute in different ways, but right now Wagi is the most common for everything but Rust programs)

While this is a quick look at the spin.toml, the entire file format is well documented.

Next, we can start Spin.

# Change into the correct directory where the `spin.toml` file is located
$ cd spin-favicon
# Start the server
$ spin up
Serving HTTP on address http://127.0.0.1:3000

When spin up starts, it will print the URL of the webserver. We can add /favicon.ico and test it out using Curl.

$ curl -v localhost:3000/favicon.ico           
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /favicon.ico HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.77.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< content-type: image/vnd.microsoft.icon
< content-length: 15086
< date: Sat, 12 Feb 2022 20:44:30 GMT
< 
Warning: Binary output can mess up your terminal. Use "--output -" to tell 
Warning: curl to output it to your terminal anyway, or consider "--output 
Warning: <FILE>" to save to a file.
* Failure writing output to destination
* Closing connection 0

Above, we can see that we successfully fetched the favicon.ico. Now, we could restart the server setting FAVICON_PATH and see the error case:

$ spin up -e FAVICON_PATH=/no/such/file.ico

Now we should see a 404 if we run curl:

*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /favicon.ico HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.77.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 404 Not Found
< content-type: text/plain
< content-length: 15
< date: Sat, 12 Feb 2022 20:46:23 GMT
< 
File not found
* Connection #0 to host localhost left intact

And there we have it! A favicon.ico server in less than 50 lines of Go code.

Conclusion

Go is a great language to write WebAssembly code, though right now we recommend using the TinyGo compiler. In this post, we skipped the usual “Hello World” and went straight to a small but useful service. We walked through the mechanics of creating a Wagi service, and then tested it out. This is an early glimpse at how we here at Fermyon are running our own services, including this website. And if Go isn’t your thing, check out the status of WebAssembly support for 20+ programming languages.

Interested in learning more?

Get Updates