Skip to content
Menu
Contents

A web server

Tutorial

Write a request handler in Bert, package it as a WebAssembly component, and serve it with Wasmtime.

What we build

A tiny HTTP server with three routes:

  • GET / says hello.
  • GET /hello/Ada greets whoever is in the path.
  • POST /greet takes JSON like {"name": "Ada"} and answers with JSON.

The handler is a plain function from a request to a response. That means you can test all of it without starting a server.

You need bert, Wasmtime and wasm-tools on your path. The Download page installs the compiler, and its step 1 installs Wasmtime and wasm-tools. If you skipped wasm-tools there, go back for it now. Wasmtime runs the server. wasm-tools wraps your compiled program into a component, a package explained in the next section. This tutorial was run with Wasmtime 50 and wasm-tools 1.227.

wasmtime --version
wasm-tools --version
Note

New things on this page. You do not need to understand all of these first. Follow along, and each one is explained where it appears:

  • Reading a type with parts in brackets, like Http.Request(Bytes.@Bytes). See Reading a type and Types with an @.
  • Choices like Get and Post that come from the Http module, not from your own code.
  • Using match on a record, to look at two things at once. See Records.
  • The pipe |>, which passes a value into the next call. See Chaining.

Components, in plain words

The program in the command-line tutorial was a plain WebAssembly file: it asked the computer for a few things, one at a time, and the computer had to know how to answer each.

A component is a WebAssembly file with a contract around it. The contract says exactly what the program needs from the program that runs it, here Wasmtime (imports), and what it offers (exports), with the types of the values that go back and forth. Anything that runs components can read that contract and know how to connect.

The contract is a small description file, written in a language called WIT. A world is the full list of what one component imports and exports. You will write a world in a few lines.

For a web server, the world is already written for you in WASI, a set of standard contracts that Wasmtime understands. wasi:http/proxy says: "I handle HTTP requests." Wasmtime calls your program once for each request.

Set up the project

Make a folder with three things in it:

project layout
server/
  bert.toml
  wit/
    world.wit
  App.bert

First, the world, in wit/world.wit:

wit/world.wit
package example:server@0.1.0;

world server {
  include wasi:http/proxy@0.2.3;
}

It names a package, defines a world called server, and says it includes the standard HTTP proxy world. Bert ships WASI 0.2.3, so you do not copy anything else in.

Next, bert.toml tells Bert to build a component for that world, and which Bert function does the work:

bert.toml
name = "hello-server"
version = "0.1.0"

[component]
world = "example:server/server@0.1.0"

[component.exports."wasi:http/incoming-handler@0.2.3"]
handle = "App.handle"
  • The [component] section is what turns the project into a component build.
  • The last table is a mapping. On the left is a name from WIT: the function handle of the interface wasi:http/incoming-handler. On the right is a Bert name: App.handle, meaning the binding handle in App.bert.

Your Bert source never mentions WIT. The mapping lives in configuration, so the code stays plain Bert.

The first handler

App.bert
text : (Int, String) -> Http.Response(Bytes.@Bytes)
text = (status, body) ->
  Http.setHeader(
    { status: status, headers: [], body: Bytes.fromString(body) },
    "content-type",
    "text/plain"
  )

router : (Http.Request(Bytes.@Bytes)) -> Http.Response(Bytes.@Bytes)
router = (request) -> text(200, "Hello from Bert!\n")

external export {
  handle : (WasiHttp.@IncomingRequest, WasiHttp.@ResponseOutparam) -> Unit
  handle = (request, out) -> WasiHttp.serve(request, out, router)
}

Three pieces:

  • router is your handler. It takes an Http.Request and gives back an Http.Response. Both are ordinary records. Its type, (Http.Request(Bytes.@Bytes)) -> Http.Response(Bytes.@Bytes), reads: it takes a request whose body is made of bytes, the raw data a server receives, and gives back a response whose body is bytes too. Bytes is raw data, before it is turned into text. Bytes.fromString turns text into bytes.
  • text is a small helper that builds a response with a status and a content type.
  • handle is what Wasmtime calls. WasiHttp.serve does the heavy lifting: it reads the incoming request, calls your router, and writes the response back. You rarely touch it again.

Build the component and serve it:

bert build App.bert --output app.wasm
wasmtime serve app.wasm
Output
Serving HTTP on http://0.0.0.0:8080/

In another terminal:

curl -i http://localhost:8080/
Output
HTTP/1.1 200 OK
content-type: text/plain
transfer-encoding: chunked

Hello from Bert!

That is a web server, built from one plain function.

Tip

The bert build command picks the component target on its own, because bert.toml has a [component] section. Pick a different port with wasmtime serve --addr 127.0.0.1:3000 app.wasm.

Routes

Now make router look at the request. Http.pathSegments splits the path into a list, so /hello/Ada becomes ["hello", "Ada"]. Put it in a record with the method, the kind of request, then match on that record with the patterns from Records and Lists:

router : (Http.Request(Bytes.@Bytes)) -> Http.Response(Bytes.@Bytes)
router = (request) -> {
  route = { method: request.method, segments: Http.pathSegments(request) }
  match route {
    { method: Get, segments: [] } -> text(200, "Hello from Bert!\n")
    { method: Get, segments: ["hello", name] } -> {
      message = "Hello, "
        |> String.concat(name)
        |> String.concat("!\n")
      text(200, message)
    }
    _ -> text(404, "not found\n")
  }
}

Each arm reads like the route it handles. ["hello", name] fits any path with exactly two parts where the first is hello, and it names the second one name. The final _ catches everything else.

Get and Post are not defined in your file: the Http module gives you these choices for the kind of request. A pattern like { method: Get, segments: [] } fits a record whose method is Get and whose segments list is empty.

|> passes the value on its left in as the first argument of the next call, so message is "Hello, ", then name, then "!\n". String.concat(a, b) joins b onto a, so each |> step adds on the right. The guide has more on chaining with |>.

Step by step, with name set to "Ada":

"Hello, "                          # => "Hello, "
  |> String.concat("Ada")          # => "Hello, Ada"
  |> String.concat("!\n")         # => "Hello, Ada!\n"

Rebuild, restart the server, and try it:

curl -i http://localhost:8080/hello/Ada
curl -i http://localhost:8080/nope
Output
HTTP/1.1 200 OK
...
Hello, Ada!

HTTP/1.1 404 Not Found
...
not found

JSON

Bert ships a Json module. A JSON value has the type Json.@Json. You build one with functions like Json.object and Json.fromString, and write it out with Json.encode.

First, a helper that sends JSON, and a function that builds our answer:

json : (Int, Json.@Json) -> Http.Response(Bytes.@Bytes)
json = (status, value) ->
  Http.setHeader(
    { status: status, headers: [], body: Bytes.fromString(Json.encode(value)) },
    "content-type",
    "application/json"
  )

greeting = (name) -> {
  message = "Hello, "
    |> String.concat(name)
    |> String.concat("!")
  Json.object([{ key: "message", value: Json.fromString(message) }])
}

Reading JSON is the other direction. Parsing can fail, and so can finding a field of the right type, so both give back a Result. A decoder is a plain function that pulls out what you want:

decodeName = (value) -> Json.field(value, "name", Json.string)

It reads: "get the field name, and expect a string". Now the handler for POST /greet. It handles three outcomes: the body is not JSON, the JSON has no name, or all is well:

greet : (Http.Request(Bytes.@Bytes)) -> Http.Response(Bytes.@Bytes)
greet = (request) -> match Json.parseBytes(request.body) {
  Err(_) -> text(400, "that is not valid JSON\n")
  Ok(value) -> match decodeName(value) {
    Ok(name) -> json(200, greeting(name))
    Err(_) -> text(400, "expected {\"name\": \"...\"}\n")
  }
}

Add two arms to router, above the _ arm. The second one answers a wrong method politely, with an allow header:

    { method: Post, segments: ["greet"] } -> greet(request)
    { segments: ["greet"] } -> Http.setHeader(text(405, "use POST\n"), "allow", "POST")

Order matters. The POST arm comes first, so only other methods reach the 405 arm.

curl -i -X POST -d '{"name":"Ada"}' http://localhost:8080/greet
curl -X POST -d '{oops' http://localhost:8080/greet
curl -i http://localhost:8080/greet
Output
HTTP/1.1 200 OK
content-type: application/json
...

{"message":"Hello, Ada!"}

that is not valid JSON

HTTP/1.1 405 Method Not Allowed
content-type: text/plain
allow: POST

Test without a server

Because router is a function, a test only needs a request value. Add helpers and tests to the bottom of App.bert:

request : (String) -> Http.Request(Bytes.@Bytes)
request = (path) -> Http.mapRequest(Http.setPath(Http.new(), path), Bytes.fromString)

bodyIs : (String) -> (Bytes.@Bytes) -> Bool
bodyIs = (expected) -> \(body) -> Bytes.eq(body, Bytes.fromString(expected))

test "the home page" {
  response = router(request("/"))
  expect(response.status, \(s) -> s == 200)
  expect(response.body, bodyIs("Hello from Bert!\n"))
}

test "hello takes the name from the path" {
  response = router(request("/hello/Ada"))
  expect(response.body, bodyIs("Hello, Ada!\n"))
}

test "an unknown path is a 404" {
  expect(router(request("/nope")).status, \(s) -> s == 404)
}

test "greet answers with JSON" {
  post = Http.setBody(Http.setMethod(request("/greet"), Post), Bytes.fromString("{\"name\": \"Ada\"}"))
  response = router(post)
  expect(response.status, \(s) -> s == 200)
  expect(response.body, bodyIs("{\"message\":\"Hello, Ada!\"}"))
}

test "greet rejects bad JSON" {
  post = Http.setBody(Http.setMethod(request("/greet"), Post), Bytes.fromString("{oops"))
  expect(router(post).status, \(s) -> s == 400)
}

test "greet only takes POST" {
  expect(router(request("/greet")).status, \(s) -> s == 405)
}

bodyIs is a function that builds a check: bodyIs("hello") gives you a function that checks a body is "hello". That is why its type has two arrows, (String) -> (Bytes.@Bytes) -> Bool. expect then calls that check on response.body.

bert test App.bert
Output
running 6 tests from App.bert

  ✓ the home page  (2 assertions)
  ✓ hello takes the name from the path  (1 assertion)
  ✓ an unknown path is a 404  (1 assertion)
  ✓ greet answers with JSON  (2 assertions)
  ✓ greet rejects bad JSON  (1 assertion)
  ✓ greet only takes POST  (1 assertion)

6 passed, 0 failed  (8 assertions)
  • Http.new() makes an empty request. The set… functions return a changed copy, as everything in Bert does.
  • handle is never built in a test run. The tests call router directly, so no server, no network, and no component tools.

The whole program

App.bert
text : (Int, String) -> Http.Response(Bytes.@Bytes)
text = (status, body) ->
  Http.setHeader(
    { status: status, headers: [], body: Bytes.fromString(body) },
    "content-type",
    "text/plain"
  )

json : (Int, Json.@Json) -> Http.Response(Bytes.@Bytes)
json = (status, value) ->
  Http.setHeader(
    { status: status, headers: [], body: Bytes.fromString(Json.encode(value)) },
    "content-type",
    "application/json"
  )

greeting = (name) -> {
  message = "Hello, "
    |> String.concat(name)
    |> String.concat("!")
  Json.object([{ key: "message", value: Json.fromString(message) }])
}

decodeName = (value) -> Json.field(value, "name", Json.string)

greet : (Http.Request(Bytes.@Bytes)) -> Http.Response(Bytes.@Bytes)
greet = (request) -> match Json.parseBytes(request.body) {
  Err(_) -> text(400, "that is not valid JSON\n")
  Ok(value) -> match decodeName(value) {
    Ok(name) -> json(200, greeting(name))
    Err(_) -> text(400, "expected {\"name\": \"...\"}\n")
  }
}

router : (Http.Request(Bytes.@Bytes)) -> Http.Response(Bytes.@Bytes)
router = (request) -> {
  route = { method: request.method, segments: Http.pathSegments(request) }
  match route {
    { method: Get, segments: [] } -> text(200, "Hello from Bert!\n")
    { method: Get, segments: ["hello", name] } -> {
      message = "Hello, "
        |> String.concat(name)
        |> String.concat("!\n")
      text(200, message)
    }
    { method: Post, segments: ["greet"] } -> greet(request)
    { segments: ["greet"] } -> Http.setHeader(text(405, "use POST\n"), "allow", "POST")
    _ -> text(404, "not found\n")
  }
}

external export {
  handle : (WasiHttp.@IncomingRequest, WasiHttp.@ResponseOutparam) -> Unit
  handle = (request, out) -> WasiHttp.serve(request, out, router)
}

request : (String) -> Http.Request(Bytes.@Bytes)
request = (path) -> Http.mapRequest(Http.setPath(Http.new(), path), Bytes.fromString)

bodyIs : (String) -> (Bytes.@Bytes) -> Bool
bodyIs = (expected) -> \(body) -> Bytes.eq(body, Bytes.fromString(expected))

test "the home page" {
  response = router(request("/"))
  expect(response.status, \(s) -> s == 200)
  expect(response.body, bodyIs("Hello from Bert!\n"))
}

test "hello takes the name from the path" {
  response = router(request("/hello/Ada"))
  expect(response.body, bodyIs("Hello, Ada!\n"))
}

test "an unknown path is a 404" {
  expect(router(request("/nope")).status, \(s) -> s == 404)
}

test "greet answers with JSON" {
  post = Http.setBody(Http.setMethod(request("/greet"), Post), Bytes.fromString("{\"name\": \"Ada\"}"))
  response = router(post)
  expect(response.status, \(s) -> s == 200)
  expect(response.body, bodyIs("{\"message\":\"Hello, Ada!\"}"))
}

test "greet rejects bad JSON" {
  post = Http.setBody(Http.setMethod(request("/greet"), Post), Bytes.fromString("{oops"))
  expect(router(post).status, \(s) -> s == 400)
}

test "greet only takes POST" {
  expect(router(request("/greet")).status, \(s) -> s == 405)
}

Look inside

Want to see the contract the build made? Ask wasm-tools:

wasm-tools component wit app.wasm
Output
world root {
  import wasi:io/error@0.2.3;
  import wasi:io/streams@0.2.3;
  import wasi:http/types@0.2.3;

  export wasi:http/incoming-handler@0.2.3;
}
...

The component offers one export, the HTTP handler, and asks only for the pieces it needs to read and write bodies. That is the whole contract. It cannot open files or sockets, because it never asked for them.

Good to know

  • No memory between requests. Each request starts fresh: nothing the handler made for one request is still there for the next. So a handler cannot keep a counter or a list of past visitors in memory. Keep such things outside the program, like in a file or another service.
  • The body is read whole. The whole request body is loaded into memory before your handler runs, so the memory your program is given limits how big a body can be.
  • Files. To read or write files, use the WasiFile module, and add import wasi:filesystem/preopens@0.2.3; to your world. Wasmtime then needs to be told which folders your program may use.
  • Other standard interfaces. WASI is the set of standard WIT contracts, like wasi:http. For one Bert has no module for, bert wit add <interface> <Module.bert> writes the Bert code and the bert.toml mapping for you. bert wit sync checks they still match the WIT.
  • Types are checked at the border. Where your Bert code meets the WIT contract, the build checks that the Bert types fit the WIT types. A mismatch is an error when you build, not a surprise while the server runs.