Building an HTTP server
This guide builds a small JSON API for todos: routing, request bodies, validation, errors as
status codes, shared state, and tests. The server is the http-server
package, written in Velt: HTTP/1.1 with keep-alive, HTTP/2, HTTPS and WebSockets. Handlers take
the web-standard Request and return a Response, and run concurrently on every core.
velt new todo --template api generates a larger version of the same design, split into
modules, with tests.
Setting up
The HTTP server is a package, not part of the standard library. Start a project and add it to
package.vlt's dependencies:
velt new todo
cd todoimport type { Package } from "velt/package";
export const pkg: Package = {
name: "todo",
version: "0.1.0",
velt: "0.5",
dependencies: {
"http-server": {
git: "https://github.com/velt-lang/velt",
tag: "v0.5.0", // the release of your velt (`velt --version`)
path: "packages/http-server",
},
},
};There is no public package registry yet, so the package comes straight from the Velt repository
(git dependencies). velt install, or the first
velt run, fetches it and records the commit in velt.lock.json. With a checkout of the
repository, velt add http-server --path ../velt/packages/http-server writes a path dependency
instead. velt new todo --template api writes the git dependency for you.
Hello, HTTP
Replace src/main.vlt with:
import { serve } from "http-server";
const server = await serve({ port: 8080 }, (req: Request) => {
return Response.json({ path: new URL(req.url).pathname, method: req.method });
});
console.log(`listening on http://127.0.0.1:${server.port}`);$ velt run
listening on http://127.0.0.1:8080$ curl localhost:8080/hi
{"path":"/hi","method":"GET"}A listening server keeps the program running after its last statement, like Node, until
server.close(). The handler receives a Request and returns a Response, or a promise of one:
the same global classes fetch uses, as in Deno and Bun. req.url is absolute
(http://localhost:8080/hi), so new URL(req.url).pathname is the path. Response.json(value)
serializes any value with JSON.stringify, which is generated at compile time for your types.
A JSON API
The API goes into its own module, src/server.vlt, with a startServer(port) function, so tests
can start it too:
import { serve, HttpServer } from "http-server";
class ApiError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
}
type Todo = { id: i64; title: string; done: bool };
class NewTodo {
title?: string; // clients may leave it out
}
class Store {
todos: Todo[] = [];
nextId: i64 = 1;
add(title: string): Todo {
const todo: Todo = { id: this.nextId, title, done: false };
this.nextId++;
this.todos.push(todo); // the same object, as in JS
return todo;
}
get(id: i64): Todo {
const todo = this.todos.find((t) => t.id === id);
if (todo == null) {
throw new ApiError(404, `no todo ${id}`);
}
return todo;
}
}
type Reply = { status: number; body: string };
function json<T>(status: number, value: T): Reply {
return { status, body: JSON.stringify(value) };
}
function parseBody(body: string): NewTodo {
try {
return JSON.parse<NewTodo>(body);
} catch (e) { // e: JsonError
throw new ApiError(400, `bad JSON: ${e.message}`);
}
}
function dispatch(store: Store, method: string, path: string, body: string): Reply {
const parts = path.split("/").filter((p) => p !== "");
if (parts.length === 0 || parts[0] !== "todos") {
throw new ApiError(404, `no route for ${path}`);
}
if (parts.length === 2 && method === "GET") {
return json(200, store.get(parseInt(parts[1], 10) as i64));
}
switch (method) {
case "GET":
return json(200, store.todos);
case "POST": {
const title = parseBody(body).title?.trim() ?? "";
if (title === "") {
throw new ApiError(400, "title is required");
}
return json(201, store.add(title));
}
default:
throw new ApiError(405, `${method} is not allowed`);
}
}
function handle(store: Store, method: string, path: string, body: string): Reply {
try {
return dispatch(store, method, path, body);
} catch (e) { // e: ApiError, the only thing dispatch throws
return json(e.status, { error: e.message });
}
}
export async function startServer(port: number): Promise<HttpServer> {
const store = shared(new Mutex<Store>(new Store()));
return await serve({ port }, async (req: Request): Promise<Response> => {
const path = new URL(req.url).pathname;
const method = req.method;
const body = await req.text();
const reply = store.with((s) => handle(s, method, path, body));
return new Response(reply.body, {
status: reply.status,
headers: { "content-type": "application/json" },
});
});
}src/main.vlt starts it:
import { startServer } from "./server";
const server = await startServer(8080);
console.log(`listening on http://127.0.0.1:${server.port}`);$ curl -X POST localhost:8080/todos -d '{"title":" milk "}'
{"id":1,"title":"milk","done":false}
$ curl -X POST localhost:8080/todos -d '{}'
{"error":"title is required"}
$ curl -X POST localhost:8080/todos -d '{"title":1}'
{"error":"bad JSON: expected string at $.title"}
$ curl localhost:8080/todos/9
{"error":"no todo 9"}
$ curl -X PUT localhost:8080/todos
{"error":"PUT is not allowed"}What to notice:
- Typed errors become status codes. Everything
dispatchcan throw is anApiError, so inhandle'scatch (e)the compiler knowseis anApiErrorande.statustype-checks.parseBodyturns theJsonErrorthatJSON.parsethrows into anApiError; without that,ewould beApiError | JsonErrorand readinge.statuswould be a compile error until you narrow it. JSON.parse<NewTodo>checks the body's shape: a wrong type is aJsonError(expected string at $.title), and an optional field (title?: string) may be missing.- Shared state is explicit. Handlers run on several threads at once, so a handler can't
change a variable it captured:
let hits = 0;instartServerandhits++in the handler is a compile error at theservecall, whose fix (shared(new Mutex(0))) it shows. The store lives inshared(new Mutex<Store>(...)), andstore.with((s) => …)locks it for the duration of the callback. Keep the work insidewithshort and synchronous; the callback can'tawait. (Aletat the top level of the program is module state, which handlers may change, as in Node: module state.) - State lives in values. The store is created by
startServerand captured by the handler, so each server, a test's included, has its own. That is also what letsvelt devhot-swap the handler's code while the store keeps its data.
Requests and responses
Request:method,url(absolute: route onnew URL(req.url).pathname, read the query fromurl.searchParams),headers(get(name)andhas(name), case-insensitive) and the body, received while you read it:await req.text(),await req.json<T>(),await req.bytes(), or chunk by chunk fromreq.body.new Response(body, { status, headers }),Response.json(value, { status }),Response.redirect(url, status);res.headers.set(name, value)sets a header.- A body made while it is sent is a
BodyStreamfrom velt/net/http:new Response(BodyStream.from(chunks))with an async generator ofu8[]chunks. Each chunk goes out as the generator yields it (Server-rendered pages streams HTML this way). - The handler's second argument,
info: ServeInfo, says who connected:info.remoteAddr.hostnameandinfo.remoteAddr.port. - A handler that throws gets a
500 Internal Server Error, and its error is printed to stderr.
The http-server reference has every option, limit and timeout.
Testing it
Start the server on port 0 (any free port), read the port from server.port, and call it with
the global fetch. In tests/api.test.vlt:
import { startServer } from "../src/server";
export async function test_create_and_validate() {
const server = await startServer(0);
const base = `http://127.0.0.1:${server.port}`;
const created = await fetch(`${base}/todos`, { method: "POST", body: `{"title":"milk"}` });
assertEq(created.status, 201);
assertEq(await created.text(), `{"id":1,"title":"milk","done":false}`);
const invalid = await fetch(`${base}/todos`, { method: "POST", body: "{}" });
assertEq(invalid.status, 400);
server.close();
}$ velt test
running tests/api.test.vlt (ran: not cacheable, it uses … the clock, the network)
ok test_create_and_validate
test result: ok. 1 passed; 0 failedThe test imports src/server.vlt, not src/main.vlt: importing a module runs its top-level
statements, and main.vlt's would start a server on port 8080. See Testing.
HTTPS and HTTP/2
Pass a PEM certificate chain and key, and the server speaks HTTPS and offers HTTP/2 through ALPN:
import { readFileSync } from "velt/fs";
const server = await serve(
{ port: 8443, tls: { cert: readFileSync("cert.pem"), key: readFileSync("key.pem") } },
handler,
);$ curl -k --http2 -s -o /dev/null -w '%{http_version}\n' https://localhost:8443/
2Without TLS the server also takes HTTP/2 from a client that starts with it
(curl --http2-prior-knowledge); http2: false turns HTTP/2 off. fetch
speaks http:// and https:// (trusting Mozilla's root certificates plus the PEM CAs you pass
in ca), and uses HTTP/2 when the server offers it.
WebSockets
http-server/websocket upgrades a request to a WebSocket. The handler returns the 101
response, and a spawned task talks on the socket, a velt/net/websocket
WebSocket:
import { serve } from "http-server";
import { isWebSocketRequest, upgradeWebSocket } from "http-server/websocket";
import { WebSocket } from "velt/net/websocket";
async function shout(ws: WebSocket): Promise<void> {
for await (const m of ws) {
await ws.send(m.text.toUpperCase());
}
await ws.close();
}
await serve({ port: 8080 }, (req: Request) => {
if (!isWebSocketRequest(req)) {
return new Response("WebSocket only", { status: 400 });
}
const up = upgradeWebSocket(req);
spawn(shout(up.socket));
return up.response;
});connectWebSocket("ws://127.0.0.1:8080/") from velt/net/websocket is the client. Topics
(ws.subscribe, ws.publish) send one message to many sockets.
velt new chat --template websocket generates a chat server with a terminal client.
Stopping and reloading
server.close() stops accepting connections, lets in-flight requests finish and closes idle
ones; await server.shutdown() also waits for them. Under
velt dev, a change to a handler's code is swapped into the running
server, and a change that needs a restart lets in-flight requests finish first; the listening
socket stays open, so no request is refused.
Databases
The database drivers are packages too, depended on like http-server:
postgres (async, with a connection pool),
sqlite (embedded) and redis. The SQL
drivers decode rows straight into your classes with the same compile-time machinery as
JSON.parse.
Performance
The TechEmpower-style suite in bench/web runs its six test types against Rust (axum),
Go (net/http), Bun and Node, with the method and the numbers in
bench/web/RESULTS.md.