A web server
TutorialWrite 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/Adagreets whoever is in the path.POST /greettakes 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.
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
GetandPostthat come from theHttpmodule, not from your own code. - Using
matchon 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:
First, the world, in wit/world.wit:
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:
- 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
handleof the interfacewasi:http/incoming-handler. On the right is a Bert name:App.handle, meaning the bindinghandleinApp.bert.
Your Bert source never mentions WIT. The mapping lives in configuration, so the code stays plain Bert.
The first handler
Three pieces:
routeris your handler. It takes anHttp.Requestand gives back anHttp.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.Bytesis raw data, before it is turned into text.Bytes.fromStringturns text into bytes.textis a small helper that builds a response with a status and a content type.handleis what Wasmtime calls.WasiHttp.servedoes the heavy lifting: it reads the incoming request, calls yourrouter, and writes the response back. You rarely touch it again.
Build the component and serve it:
In another terminal:
That is a web server, built from one plain function.
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:
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":
Rebuild, restart the server, and try it:
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:
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:
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:
Add two arms to router, above the _ arm. The second one answers a wrong method politely, with an allow header:
Order matters. The POST arm comes first, so only other methods reach the 405 arm.
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:
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.
Http.new()makes an empty request. Theset…functions return a changed copy, as everything in Bert does.handleis never built in a test run. The tests callrouterdirectly, so no server, no network, and no component tools.
The whole program
Look inside
Want to see the contract the build made? Ask wasm-tools:
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
WasiFilemodule, and addimport 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 thebert.tomlmapping for you.bert wit syncchecks 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.