Skip to content
The book

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:

Terminal
velt new todo
cd todo
package.vlt
import 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:

src/main.vlt
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}`);
Terminal
$ velt run
listening on http://127.0.0.1:8080
Terminal
$ 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:

src/server.vlt
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:

src/main.vlt
import { startServer } from "./server";

const server = await startServer(8080);
console.log(`listening on http://127.0.0.1:${server.port}`);
Terminal
$ 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 dispatch can throw is an ApiError, so in handle's catch (e) the compiler knows e is an ApiError and e.status type-checks. parseBody turns the JsonError that JSON.parse throws into an ApiError; without that, e would be ApiError | JsonError and reading e.status would be a compile error until you narrow it.
  • JSON.parse<NewTodo> checks the body's shape: a wrong type is a JsonError (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; in startServer and hits++ in the handler is a compile error at the serve call, whose fix (shared(new Mutex(0))) it shows. The store lives in shared(new Mutex<Store>(...)), and store.with((s) => …) locks it for the duration of the callback. Keep the work inside with short and synchronous; the callback can't await. (A let at 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 startServer and captured by the handler, so each server, a test's included, has its own. That is also what lets velt dev hot-swap the handler's code while the store keeps its data.

Requests and responses#

  • Request: method, url (absolute: route on new URL(req.url).pathname, read the query from url.searchParams), headers (get(name) and has(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 from req.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 BodyStream from velt/net/http: new Response(BodyStream.from(chunks)) with an async generator of u8[] 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.hostname and info.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:

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();
}
Terminal
$ 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 failed

The 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:

ts
import { readFileSync } from "velt/fs";

const server = await serve(
  { port: 8443, tls: { cert: readFileSync("cert.pem"), key: readFileSync("key.pem") } },
  handler,
);
Terminal
$ curl -k --http2 -s -o /dev/null -w '%{http_version}\n' https://localhost:8443/
2

Without 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:

ts
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.

Edit this page on GitHub