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.
Hello world
Section titled “Hello world”import "wings";
func index(req) { return {"hello": "world"};}
get("/", index); // bind the handler by name — not a stringserve(); // no port → default 8484; serve(8080) for explicitThat’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.
Routing
Section titled “Routing”get("/users/:id", show_user); // path params arrive in req.paramspost("/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:
| Field | Type | Notes |
|---|---|---|
method | str | GET, POST, PUT, DELETE, … |
path | str | URL path without the query string. |
raw_path | str | Path + query as the client sent it. |
query | json | Parsed ?k=v&... (URL-decoded). |
headers | json | Header map (case-sensitive keys). |
body | str | Raw body bytes, length-bounded. |
Middleware (use)
Section titled “Middleware (use)”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.
Route groups (group)
Section titled “Route groups (group)”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).
Dependency injection (depends / dep)
Section titled “Dependency injection (depends / dep)”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.
Single-thread vs multi-thread
Section titled “Single-thread vs multi-thread”listen(8080); // single accept loop, keep-alive on each connectionlisten_async(8080); // thread-per-connection, parallel recv/sendlisten() 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.
Auto-routes
Section titled “Auto-routes”If you don’t register them yourself, listen() and listen_async()
auto-register two routes that production deployments expect:
/healthz
Section titled “/healthz”{ "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.
/metrics
Section titled “/metrics”{ "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().
OpenAPI auto-generation
Section titled “OpenAPI auto-generation”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.
Structured logging
Section titled “Structured logging”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.
Response helpers
Section titled “Response helpers”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.
Reading the request body
Section titled “Reading the request body”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.
Validating the request body
Section titled “Validating the request body”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 ? = optionalPOST {"name": 123} → 422 {"error":"validation failed", "fields":{"name":"expected str, got int"}}POST {} → 422 {"fields":{"name":"required"}}POST {"name":"Ada"} → reaches create_userTypes: str, int, float, num (int or float), bool, array,
object, json (object or array), any (present, any type).
Typed query parameters
Section titled “Typed query parameters”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.
Response model (output shaping)
Section titled “Response model (output shaping)”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. droppedWorks on a single object or an array of objects. Errors (status ≥ 400) bypass
filtering, so a {"error": ...} body is never stripped.
File uploads (multipart/form-data)
Section titled “File uploads (multipart/form-data)”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.
Interactive docs (Swagger UI)
Section titled “Interactive docs (Swagger UI)”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.
Keeping in-memory state (auto-persist)
Section titled “Keeping in-memory state (auto-persist)”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()Custom responses
Section titled “Custom responses”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 file serving (static)
Section titled “Static file serving (static)”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.cssA 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.htmlPath 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.
HTTPS (Wings TLS)
Section titled “HTTPS (Wings TLS)”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_accept → SSL_read → handler dispatch → SSL_write
→ SSL_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.
Cert + key
Section titled “Cert + key”For local dev, generate a self-signed cert valid for a year:
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");Build requirement
Section titled “Build requirement”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.
What’s not in v1 yet
Section titled “What’s not in v1 yet”- 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.