Skip to content

HTTP Server (Wings)

Wings is the embedded HTTP framework that ships with Tulpar. It’s designed to take you from import "wings" to a production-shaped JSON API in under 20 lines of code, with health checks, metrics, OpenAPI documentation, and multi-threaded request handling already wired up.

import "wings";
func index(req) {
return {"hello": "world"};
}
get("/", index); // bind the handler by name — not a string
serve(); // no port → default 8484; serve(8080) for explicit

That’s a complete HTTP/1.1 server with keep-alive, CORS-friendly default headers, and /healthz + /metrics auto-registered. Routes bind the handler function (index, not "index") — a typo is a compile error, not a silent 404.

Calling serve() with no port uses Tulpar’s default 8484 (ASCII T = 84 → binary 01010100, the “Tulpar port”); if it’s already taken it walks up (8485, 8486, …) so a second app still starts. Pass an explicit port — serve(8080) — and Wings binds exactly that, telling you if it’s in use rather than silently moving. serve() is listen(); both accept the same optional port.

get("/users/:id", show_user); // path params arrive in req.params
post("/users", create_user);
put("/users/:id", update_user);
del("/users/:id", delete_user);
func show_user(req) {
return orm_find("users", toInt(req.params.id));
}

Each handler receives the request as its first parameter (req). You can read fields with dotted access — req.params.id, req.query.page, req.json (auto-parsed body). The same data is also on the global _request, so a handler that takes no parameter still works:

FieldTypeNotes
methodstrGET, POST, PUT, DELETE, …
pathstrURL path without the query string.
raw_pathstrPath + query as the client sent it.
queryjsonParsed ?k=v&... (URL-decoded).
headersjsonHeader map (case-sensitive keys).
bodystrRaw body bytes, length-bounded.

Register a global middleware with use("fn_name"). Every registered middleware runs — in registration order — before the matched handler, across all serve modes (there’s a single dispatch point). A middleware is a func mw(req) that either returns a response dict (_status set → the chain short-circuits and that response is sent) or returns {} (continue). It can mutate req in place, so later middleware and the handler see the change:

func require_auth(req) {
str token = req["headers"]["Authorization"];
if (length(token) == 0) {
return unauthorized("token required"); // short-circuit → 401
}
req["user"] = {"id": 1, "name": "Ada"}; // visible to the handler
return {}; // continue
}
use("require_auth");

When no middleware is registered the chain is zero-cost — Wings skips it entirely on the hot path.

group(prefix, register_fn) runs register_fn (a function name) with a path prefix applied to every route it registers. The prefix is saved and restored around the call, so groups nest:

func api_v1() {
get("/users", "list_users"); // → /api/v1/users
post("/users", "create_user"); // → /api/v1/users
}
group("/api/v1", "api_v1");

get / post / put / del / cached_get all honor the active prefix, so inner group(...) calls concatenate (/api/v1 + /admin/api/v1/admin).

FastAPI-style per-route dependencies. depends("fn_name") attaches a dependency to the most recently registered route; before the handler runs, each dependency is called with req and its return value is injected. The handler reads it with dep("name"). A dependency can short-circuit by returning a response dict (_status set) — exactly like middleware, but per-route and value-producing:

func current_user(req) {
str t = req["headers"]["Authorization"];
if (length(t) == 0) { return unauthorized("token required"); }
return {"id": 1, "name": "Ada"};
}
func profile(req) { return ok(dep("current_user")); }
get("/profile", "profile");
depends("current_user");

Resolved values live in a thread-local store, so they don’t leak between concurrent requests under listen_pool / listen_async.

listen(8080); // single accept loop, keep-alive on each connection
listen_async(8080); // thread-per-connection, parallel recv/send

listen() is the battle-tested single-thread server: it serves multiple keep-alive requests on each accepted socket, but new connections wait until the current one returns to accept(). It has the lowest per-request latency (~0.22 ms p50) but, being a serial accept loop, it serialises concurrent keep-alive connections — so for raw throughput prefer listen_pool (which out-throughputs Go net/http). See the Benchmarks page for the full comparison.

listen_async() spawns a detached worker per accepted TCP connection. Each worker does its own recv / parse / send in parallel; handler dispatch is serialised under _wings_handler_mu until LLVM thread-local globals land, but the network parts run concurrently. The win is: multi-thread accept (no one slow request blocks others) and parallel keep-alive serving for many idle clients.

If you don’t register them yourself, listen() and listen_async() auto-register two routes that production deployments expect:

{
"status": "ok",
"uptime_s": 142,
"now": "2026-05-02T18:34:21Z"
}

Drop-in compatible with Kubernetes liveness probes. Override by registering your own GET /healthz before calling listen.

{
"uptime_s": 142,
"requests_total": 5021,
"requests_2xx": 4998,
"requests_4xx": 23,
"requests_5xx": 0,
"routes": 7
}

Tracks counters that Wings increments on every response. For Prometheus scrapers, add ?format=prom to get text exposition format, or build a dedicated route with wings_metrics_prom().

func openapi_handler() {
return wings_openapi("My API", "1.0.0");
}
get("/openapi.json", "openapi_handler");

wings_openapi(title, version) walks the registered routes and emits an OpenAPI 3.0 document. Swagger UI / Postman / Insomnia consume it directly. Today’s coverage: every route’s path + method + handler name. Request / response schemas are placeholder; richer route metadata is on the roadmap.

log_info("user signed up: " + email);
log_error("payment failed: " + toString(code));

Output (one JSON object per line — log aggregator-friendly):

{"@timestamp":"2026-05-02T18:34:21Z","level":"info","msg":"user signed up: a@b.c"}
{"@timestamp":"2026-05-02T18:34:21Z","level":"error","msg":"payment failed: 502"}

The Wings access log itself uses the same format. To silence the per-request access line on benchmark / production paths, set TULPAR_HTTP_QUIET=1.

Return a plain object and it’s serialized as 200 application/json. For other status codes, use the helpers instead of hand-writing the {"_status": N} envelope — they read as intent:

func get_user(req) {
json u = find_user();
if (!u) { return not_found("no such user"); }
return ok(u); // 200
}
func create_user(req) {
return created(new_user()); // 201
}

Available: ok(data), created(data) (201), no_content() (204), bad_request(msg) (400), unauthorized(msg) (401), forbidden(msg) (403), not_found(msg) (404), conflict(msg) (409), server_error(msg) (500). Error helpers share a uniform {"error": msg} body. text(body) returns plain text; with_status(data, code) sets any status.

A JSON request body is parsed for you — read _request["json"] directly, no fromJson() needed:

func create_user(req) {
json body = req.json; // already parsed
if (length(body["name"]) == 0) {
return bad_request("name required");
}
return created({"name": body["name"]});
}

req (and the global _request) also carries params (path captures like :id), query, headers, cookies, body (raw), and method / path.

Attach a schema to a route with body_schema() right after registering it. The body is checked before your handler runs — an invalid body never reaches your code and gets an automatic 422 listing every offending field:

post("/users", create_user);
body_schema({"name": "str", "age?": "int"}); // trailing ? = optional
POST {"name": 123} → 422 {"error":"validation failed",
"fields":{"name":"expected str, got int"}}
POST {} → 422 {"fields":{"name":"required"}}
POST {"name":"Ada"} → reaches create_user

Types: str, int, float, num (int or float), bool, array, object, json (object or array), any (present, any type).

req["query"] holds raw string values. The typed accessors coerce with a default fallback, so a list endpoint reads pagination/filters in one line instead of hand-checking presence and calling toInt():

func list_items(req) {
int page = query_int(req, "page", 1); // ?page=2 → 2, missing → 1
str sort = query(req, "sort", "id"); // ?sort=name → "name"
bool desc = query_bool(req, "desc", false); // 1/true/yes → true, 0/false/no → false
return ok(fetch_items(page, sort, desc));
}

query_int returns the fallback on a missing or empty value; query_bool accepts 1/true/yes and 0/false/no (case-insensitive) and falls back otherwise.

The symmetric counterpart to body_schema(): attach a schema to the most recently registered route to filter successful output down to only the declared fields. Anything not listed (a password_hash, an _internal flag) is dropped before serialization, so a handler can return its full row without leaking secrets:

get("/users/:id", "show_user");
response_model({"id": "int", "name": "str"}); // password etc. dropped

Works on a single object or an array of objects. Errors (status ≥ 400) bypass filtering, so a {"error": ...} body is never stripped.

For multipart/form-data requests, read text fields with form(req, name, fallback) and uploaded files with uploaded_files(req) — an array of {name, filename, content_type, data, size}. File data carries the raw bytes (binary-safe, length-tracked), so PNGs and other binaries round-trip byte-exact:

func upload(req) {
str title = form(req, "title", "");
for (f in uploaded_files(req)) {
write_file("./uploads/" + f["filename"], f["data"]);
}
return created({"title": title, "count": length(uploaded_files(req))});
}
post("/upload", "upload");

Parsing is lazy (only when you call form / uploaded_files) and backed by the native parse_multipart builtin. Each call re-parses; for many fields, call form_data(req) once and read the returned {fields, files} directly.

serve() auto-mounts /docs (Swagger UI) backed by /openapi.json, generated from your routes — :id path params and every body_schema() become OpenAPI parameters and request bodies. Set the title/version with docs_info("Users API", "1.0.0") before serve(). Opt out with the env var TULPAR_WINGS_NODOCS=1 or by claiming /docs yourself.

Wings recycles each request’s memory after the response (an arena reset), so a value a handler built is gone once the request ends. To keep data in a long-lived global — the in-memory “database” pattern — just write to the global; Tulpar handles the rest. Writes rooted in a global (push(_users, u), _users[i]["name"] = v, _g = v) are auto-persisted at compile time — no manual call needed:

import "wings";
json _users = [{"id": 1, "name": "Ada"}];
int _next_id = 2;
func list_users(req) { return ok(_users); }
func create_user(req) {
json body = req.json;
json u = {"id": _next_id, "name": body["name"]};
_next_id = _next_id + 1;
push(_users, u); // push into a global → auto-persisted
return created(u);
}
get("/users", list_users);
post("/users", create_user);
serve(8080); // serve() == listen()

The default 200 application/json plus CORS headers covers most cases. For a custom content type with a plain-string body, return the _raw / _content_type envelope — Wings sends the string verbatim instead of JSON-encoding it:

func export_csv(req) {
str body = "id,name\n1,Ada\n2,Linus\n";
return {"_raw": body, "_content_type": "text/csv; charset=utf-8"};
}

When you also need custom headers — a Content-Disposition download filename, say — write the full response to the socket yourself with http_create_response(...), then return {"_stream": 1} so Wings knows you’ve already handled the wire and skips its own framing:

func download_csv(req) {
str body = "id,name\n1,Ada\n2,Linus\n";
json headers = {"Content-Disposition": "attachment; filename=\"users.csv\""};
str wire = http_create_response(200, "text/csv; charset=utf-8", body, headers, 0);
socket_send(wings_current_fd(), wire);
return {"_stream": 1};
}

http_create_response(status, content_type, body, headers, keep_alive) is the underlying primitive; wings_current_fd() is the active request socket. This same {"_stream": 1} pattern is how you’d implement SSE or a WebSocket upgrade.

static(url_prefix, dir) mounts a directory under a URL prefix. Files are served as a 404-fallback: real routes (exact or :param) always match first, and static is the catch-all checked only when nothing else matches:

static("/static", "./public"); // GET /static/app.css → ./public/app.css

A directory-style request (the mount root, or a path ending in /) serves index.html, so you can host a single-page app straight from root:

static("/", "./public"); // GET / → ./public/index.html

Path traversal (..) is rejected. Text assets (html/css/js/json/ svg/txt) get a correct Content-Type; everything else falls back to application/octet-stream. Binary assets (a PNG with embedded NULs) round-trip byte-exact, since read_file / http_create_response are length-tracked rather than strlen-based.

Wings ships an HTTPS listener alongside the plain-HTTP one. Same handler API, same routing — only the listen call changes:

import "wings";
import "wings_tls";
func home() {
return {"message": "Hello over TLS", "secure": true};
}
get("/", "home");
wings_tls(8443, "./server.crt", "./server.key");

That’s a complete HTTPS server. Every accepted connection runs through SSL_acceptSSL_read → handler dispatch → SSL_writeSSL_shutdown. The SSL_CTX is built once at startup; cert and key files are read at the point of wings_tls(...), not on every request.

For local dev, generate a self-signed cert valid for a year:

Terminal window
openssl req -x509 -newkey rsa:2048 \
-keyout server.key -out server.crt \
-days 365 -nodes \
-subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,IP:127.0.0.1"

Client side, curl --insecure https://127.0.0.1:8443/ trusts the self-signed cert; browsers will show a warning and let you click through.

For production, point at your real cert chain — Let’s Encrypt’s /etc/letsencrypt/live/<domain>/fullchain.pem and privkey.pem are the canonical pair, and wings_tls reads them like any other PEM file:

wings_tls(443,
"/etc/letsencrypt/live/tulparlang.dev/fullchain.pem",
"/etc/letsencrypt/live/tulparlang.dev/privkey.pem");

wings_tls is OpenSSL-backed. Tulpar’s find_package(OpenSSL) must have succeeded at CMake time — on Linux that means apt install libssl-dev before building, on MSYS2 Windows it’s pacman -S mingw-w64-x86_64-openssl, and macOS picks it up from brew install openssl. If OpenSSL wasn’t linked in, tls_init returns 0 and wings_tls prints an error and exits before binding the socket.

  • TLS keep-alive. Each accepted TLS connection serves one request and closes. The plain-HTTP listen() does multi-request keep-alive; the TLS path will pick that up as part of the partial-read state machine work that lands alongside _wings_serve_one_request-style splitting.
  • Client-cert verification (mTLS). The TLS context is configured for server-auth-only today. Adding SSL_CTX_set_verify
    • CA bundle loading is a small follow-up.
  • HTTP/2 + ALPN. The framework speaks HTTP/1.1 over TLS; HTTP/2 negotiation via ALPN is a future addition.