module http
module lib/http.mu
import "http"
http provides minimal HTTP/1.1 helpers for parsing, formatting, routing, serving, and making plain-text client requests over lib/sockets.
Scope is deliberately small: no TLS, no chunked transfer encoding, no DNS, and no persistent connection pooling. Socket-backed helpers speak HTTP over tcp:// sockets and expect numeric hosts, matching the sockets module.
Imports
Functions
client#
client constructs a reusable plain-HTTP client with optional default headers.
Source lib/http.mu:643
fn client(base_url, defaults...) {
parsed := parse_url(base_url)
if is_error(parsed) {
return parsed
}
headers := {}
if len(defaults) > 1 {
return error("http.client expects at most one defaults map")
}
if len(defaults) == 1 {
opts := defaults[0]
if opts == nil {
opts = {}
}
if !typing.is_map(opts) {
return error("http.client expects map defaults, got " + type(opts))
}
if opts["headers"] != nil {
headers = opts["headers"]
}
}
normalized := _normalize_headers(headers, "http.client")
if is_error(normalized) {
return normalized
}
return {
"__type": "http.Client",
"base_url": base_url,
"origin": "http://" + parsed["authority"],
"base_path": _base_path_from_target(parsed["target"]),
"headers": normalized,
}
}client_get#
client_get sends a GET request through a reusable client.
Source lib/http.mu:695
fn client_get(c, target) {
return client_request(c, "GET", target, {}, "")
}client_post#
client_post sends a POST request through a reusable client.
Source lib/http.mu:700
fn client_post(c, target, body, headers...) {
h := {}
if len(headers) > 1 {
return error("http.client_post expects at most one headers map")
}
if len(headers) == 1 {
h = headers[0]
}
return client_request(c, "POST", target, h, body)
}client_request#
client_request sends a request through a reusable client.
Source lib/http.mu:678
fn client_request(c, method, target, headers, body) {
err := _require_client(c, "http.client_request")
if is_error(err) {
return err
}
merged := _merge_headers(c["headers"], headers, "http.client_request")
if is_error(merged) {
return merged
}
target_url := _client_url(c, target)
if is_error(target_url) {
return target_url
}
return request(method, target_url, merged, body)
}close#
close closes a server's listener.
Source lib/http.mu:532
fn close(server) {
if !typing.is_map(server) {
return error("http.close expects server map, got " + type(server))
}
return sockets.close(server["listener"])
}content_length#
content_length returns the parsed Content-Length header, defaulting to zero.
Source lib/http.mu:51
fn content_length(headers) {
value := header_get(headers, "content-length")
if is_error(value) {
return value
}
if value == nil {
return 0
}
parsed := int(value)
if is_error(parsed) || parsed < 0 {
return error("http.content_length: invalid Content-Length " + inspect(value))
}
return parsed
}delete#
delete registers a DELETE route on m.
Source lib/http.mu:405
fn delete(m, path, handler) {
return handle(m, "DELETE", path, handler)
}dispatch#
dispatch runs the exact handler for req's method/path, returning 404 or 405 otherwise.
Source lib/http.mu:449
fn dispatch(m, req) {
err := _require_mux(m, "http.dispatch")
if is_error(err) {
return err
}
if !typing.is_map(req) {
return error("http.dispatch expects request map, got " + type(req))
}
method := _normalize_method(req["method"], "http.dispatch")
if is_error(method) {
return method
}
path := _normalize_route_path(req["path"], "http.dispatch")
if is_error(path) {
return path
}
handler := m["exact"][_route_key(method, path)]
if handler != nil {
return _coerce_response(handler(req), "http.dispatch")
}
allowed := m["allowed"][path]
if allowed != nil {
return _method_not_allowed(m, req, allowed)
}
return _not_found(m, req)
}fetch#
fetch sends one request. opts may contain method, headers, and body.
Source lib/http.mu:613
fn fetch(target_url, opts...) {
options := {}
if len(opts) > 1 {
return error("http.fetch expects at most one opts map")
}
if len(opts) == 1 {
options = opts[0]
if options == nil {
options = {}
}
if !typing.is_map(options) {
return error("http.fetch expects map opts, got " + type(options))
}
}
method := options["method"]
if method == nil {
method = "GET"
}
headers := options["headers"]
if headers == nil {
headers = {}
}
body := options["body"]
if body == nil {
body = ""
}
return request(method, target_url, headers, body)
}format_request#
format_request serializes a request map as HTTP/1.1 text.
Source lib/http.mu:192
fn format_request(req) {
if !typing.is_map(req) {
return error("http.format_request expects request map, got " + type(req))
}
method := req["method"]
if !typing.is_str(method) || method == "" {
return error("http.format_request expects string method")
}
target := req["target"]
if target == nil {
target = req["path"]
}
if target == nil || target == "" {
target = "/"
}
if !typing.is_str(target) {
return error("http.format_request expects string target")
}
version := req["version"]
if version == nil {
version = "HTTP/1.1"
}
if !typing.is_str(version) {
return error("http.format_request expects string version")
}
headers := _normalize_headers(req["headers"], "http.format_request")
if is_error(headers) {
return headers
}
body := _string_or_empty(req["body"], "http.format_request", "body")
if is_error(body) {
return body
}
if len(body) > 0 && header_get(headers, "content-length") == nil {
headers["content-length"] = str(len(body))
}
if header_get(headers, "connection") == nil {
headers["connection"] = "close"
}
return strings.upper(method) + " " + target + " " + version + "\r\n" +
_format_headers(headers) + "\r\n" + body
}format_response#
format_response serializes a response map as HTTP/1.1 text.
Source lib/http.mu:280
fn format_response(resp) {
if !typing.is_map(resp) {
return error("http.format_response expects response map, got " + type(resp))
}
status := resp["status"]
if !typing.is_int(status) {
return error("http.format_response expects integer status")
}
version := resp["version"]
if version == nil {
version = "HTTP/1.1"
}
if !typing.is_str(version) {
return error("http.format_response expects string version")
}
reason := resp["reason"]
if reason == nil {
reason = status_text(status)
}
if !typing.is_str(reason) {
return error("http.format_response expects string reason")
}
headers := _normalize_headers(resp["headers"], "http.format_response")
if is_error(headers) {
return headers
}
body := _string_or_empty(resp["body"], "http.format_response", "body")
if is_error(body) {
return body
}
if header_get(headers, "content-length") == nil {
headers["content-length"] = str(len(body))
}
if header_get(headers, "connection") == nil {
headers["connection"] = "close"
}
return version + " " + str(status) + " " + reason + "\r\n" +
_format_headers(headers) + "\r\n" + body
}get#
get registers a GET route on m.
Source lib/http.mu:385
fn get(m, path, handler) {
return handle(m, "GET", path, handler)
}handle#
handle registers handler for method/path on m and returns m.
Source lib/http.mu:353
fn handle(m, method, path, handler) {
err := _require_mux(m, "http.handle")
if is_error(err) {
return err
}
normalized := _normalize_method(method, "http.handle")
if is_error(normalized) {
return normalized
}
checked_path := _normalize_route_path(path, "http.handle")
if is_error(checked_path) {
return checked_path
}
if !typing.is_function(handler) {
return error("http.handle expects function handler, got " + type(handler))
}
key := _route_key(normalized, checked_path)
exact := m["exact"]
if has(exact, key) {
return error("http.handle: route already registered " + normalized + " " + checked_path)
}
exact[key] = handler
methods := m["allowed"][checked_path]
if methods == nil {
methods = {}
m["allowed"][checked_path] = methods
}
methods[normalized] = true
return m
}header_get#
header_get returns a header value by case-insensitive name, or nil when absent.
Source lib/http.mu:24
fn header_get(headers, name) {
if headers == nil {
return nil
}
if !typing.is_map(headers) {
return error("http.header_get expects map headers, got " + type(headers))
}
wanted := normalize_header_name(name)
if is_error(wanted) {
return wanted
}
ks := keys(headers)
i := 0
while i < len(ks) {
key := ks[i]
if !typing.is_str(key) {
return error("http.header_get expects string header names, got " + type(key))
}
if strings.lower(key) == wanted {
return headers[key]
}
i = i + 1
}
return nil
}html#
html returns a text/html response with a UTF-8 content type.
Source lib/http.mu:127
fn html(status, body) {
return new_response(status, {"content-type": "text/html; charset=utf-8"}, body)
}is_response#
is_response reports whether value has the minimal response-map shape.
Source lib/http.mu:335
fn is_response(value) {
return typing.is_map(value) && typing.is_int(value["status"])
}listen#
listen creates a server map containing a TCP listener and mux.
Source lib/http.mu:477
fn listen(addr, m) {
err := _require_mux(m, "http.listen")
if is_error(err) {
return err
}
listener := sockets.listen(addr)
if is_error(listener) {
return listener
}
return {"listener": listener, "mux": m}
}mux#
mux creates an exact-route multiplexer. Route lookup is a map lookup keyed by method+path, with a second map tracking allowed methods for 405 responses.
Source lib/http.mu:341
fn mux() {
return {
"__type": "http.Mux",
"exact": {},
"allowed": {},
"not_found": nil,
"method_not_allowed": nil,
"error": nil,
}
}new_request#
new_request constructs a request map suitable for format_request or dispatch.
Source lib/http.mu:67
fn new_request(method, target, headers, body) {
if !typing.is_str(method) {
return error("http.new_request expects string method, got " + type(method))
}
if !typing.is_str(target) {
return error("http.new_request expects string target, got " + type(target))
}
normalized := _normalize_headers(headers, "http.new_request")
if is_error(normalized) {
return normalized
}
payload := _string_or_empty(body, "http.new_request", "body")
if is_error(payload) {
return payload
}
split := _split_target(target)
if is_error(split) {
return split
}
return {
"method": strings.upper(method),
"target": target,
"path": split["path"],
"query": split["query"],
"version": "HTTP/1.1",
"headers": normalized,
"body": payload,
}
}new_response#
new_response constructs a response map suitable for format_response.
Source lib/http.mu:98
fn new_response(status, headers, body) {
if !typing.is_int(status) {
return error("http.new_response expects integer status, got " + type(status))
}
if status < 100 || status > 999 {
return error("http.new_response expects status in 100..999, got " + str(status))
}
normalized := _normalize_headers(headers, "http.new_response")
if is_error(normalized) {
return normalized
}
payload := _string_or_empty(body, "http.new_response", "body")
if is_error(payload) {
return payload
}
return {
"status": status,
"reason": status_text(status),
"headers": normalized,
"body": payload,
}
}normalize_header_name#
normalize_header_name lower-cases and trims an HTTP header field name.
Source lib/http.mu:16
fn normalize_header_name(name) {
if !typing.is_str(name) {
return error("http.normalize_header_name expects string name, got " + type(name))
}
return strings.lower(strings.trim(name))
}on_error#
on_error installs a custom server-error handler and returns m.
Source lib/http.mu:436
fn on_error(m, handler) {
err := _require_mux(m, "http.on_error")
if is_error(err) {
return err
}
if !typing.is_function(handler) {
return error("http.on_error expects function handler, got " + type(handler))
}
m["error"] = handler
return m
}on_method_not_allowed#
on_method_not_allowed installs a custom 405 handler and returns m.
Source lib/http.mu:423
fn on_method_not_allowed(m, handler) {
err := _require_mux(m, "http.on_method_not_allowed")
if is_error(err) {
return err
}
if !typing.is_function(handler) {
return error("http.on_method_not_allowed expects function handler, got " + type(handler))
}
m["method_not_allowed"] = handler
return m
}on_not_found#
on_not_found installs a custom 404 handler and returns m.
Source lib/http.mu:410
fn on_not_found(m, handler) {
err := _require_mux(m, "http.on_not_found")
if is_error(err) {
return err
}
if !typing.is_function(handler) {
return error("http.on_not_found expects function handler, got " + type(handler))
}
m["not_found"] = handler
return m
}parse_request#
parse_request parses an HTTP request string into a request map.
Source lib/http.mu:153
fn parse_request(raw) {
if !typing.is_str(raw) {
return error("http.parse_request expects string raw, got " + type(raw))
}
parts := _split_message(raw, "http.parse_request")
if is_error(parts) {
return parts
}
lines := _head_lines(parts["head"])
if len(lines) == 0 || lines[0] == "" {
return error("http.parse_request: missing request line")
}
start := strings.split(lines[0], " ")
if len(start) != 3 || start[0] == "" || start[1] == "" || start[2] == "" {
return error("http.parse_request: malformed request line")
}
if !strings.starts_with(start[2], "HTTP/") {
return error("http.parse_request: malformed HTTP version")
}
headers := _parse_headers(lines, 1, "http.parse_request")
if is_error(headers) {
return headers
}
split := _split_target(start[1])
if is_error(split) {
return split
}
return {
"method": strings.upper(start[0]),
"target": start[1],
"path": split["path"],
"query": split["query"],
"version": start[2],
"headers": headers,
"body": parts["body"],
}
}parse_response#
parse_response parses an HTTP response string into a response map.
Source lib/http.mu:250
fn parse_response(raw) {
if !typing.is_str(raw) {
return error("http.parse_response expects string raw, got " + type(raw))
}
parts := _split_message(raw, "http.parse_response")
if is_error(parts) {
return parts
}
lines := _head_lines(parts["head"])
if len(lines) == 0 || lines[0] == "" {
return error("http.parse_response: missing status line")
}
parsed := _parse_status_line(lines[0])
if is_error(parsed) {
return parsed
}
headers := _parse_headers(lines, 1, "http.parse_response")
if is_error(headers) {
return headers
}
return {
"version": parsed["version"],
"status": parsed["status"],
"reason": parsed["reason"],
"headers": headers,
"body": parts["body"],
}
}parse_url#
parse_url parses a plain http:// URL into a socket address and request target.
Source lib/http.mu:540
fn parse_url(value) {
if !typing.is_str(value) {
return error("http.parse_url expects string value, got " + type(value))
}
if !strings.starts_with(value, "http://") {
return error("http.parse_url supports only http:// URLs")
}
rest := strings.substring(value, 7, len(value))
fragment := strings.index_of(rest, "#")
if fragment >= 0 {
rest = strings.substring(rest, 0, fragment)
}
slash := strings.index_of(rest, "/")
query := strings.index_of(rest, "?")
authority := rest
target := "/"
if query >= 0 && (slash < 0 || query < slash) {
authority = strings.substring(rest, 0, query)
target = "/" + strings.substring(rest, query, len(rest))
} else if slash >= 0 {
authority = strings.substring(rest, 0, slash)
target = strings.substring(rest, slash, len(rest))
}
if authority == "" {
return error("http.parse_url: missing host")
}
if strings.starts_with(authority, "[") && strings.index_of(authority, "]") < 0 {
return error("http.parse_url: malformed IPv6 host")
}
socket_authority := authority
if !_authority_has_port(authority) {
socket_authority = authority + ":80"
}
return {
"scheme": "http",
"authority": authority,
"addr": "tcp://" + socket_authority,
"target": target,
}
}patch#
patch registers a PATCH route on m.
Source lib/http.mu:400
fn patch(m, path, handler) {
return handle(m, "PATCH", path, handler)
}post#
post registers a POST route on m.
Source lib/http.mu:390
fn post(m, path, handler) {
return handle(m, "POST", path, handler)
}put#
put registers a PUT route on m.
Source lib/http.mu:395
fn put(m, path, handler) {
return handle(m, "PUT", path, handler)
}read_request#
read_request reads one HTTP request from a TCP connection.
Source lib/http.mu:236
fn read_request(conn) {
return _read_message(conn, parse_request, false, "http.read_request")
}read_response#
read_response reads one HTTP response from a TCP connection.
Source lib/http.mu:321
fn read_response(conn) {
return _read_message(conn, parse_response, true, "http.read_response")
}request#
request sends one HTTP request to url and returns the parsed response.
Source lib/http.mu:582
fn request(method, target_url, headers, body) {
parsed := parse_url(target_url)
if is_error(parsed) {
return parsed
}
normalized := _normalize_headers(headers, "http.request")
if is_error(normalized) {
return normalized
}
if header_get(normalized, "host") == nil {
normalized["host"] = parsed["authority"]
}
req := new_request(method, parsed["target"], normalized, body)
if is_error(req) {
return req
}
conn := sockets.dial(parsed["addr"])
if is_error(conn) {
return conn
}
wrote := write_request(conn, req)
if is_error(wrote) {
sockets.close(conn)
return wrote
}
resp := read_response(conn)
sockets.close(conn)
return resp
}serve#
serve accepts connections forever, starting one task per connection.
Source lib/http.mu:490
fn serve(server) {
if !typing.is_map(server) {
return error("http.serve expects server map, got " + type(server))
}
listener := server["listener"]
m := server["mux"]
err := _require_mux(m, "http.serve")
if is_error(err) {
return err
}
while true {
conn := sockets.accept(listener)
if is_error(conn) {
return conn
}
task(_serve_conn_task, conn, m)
}
}serve_conn#
serve_conn reads a request, dispatches it, writes a response, and closes conn.
Source lib/http.mu:510
fn serve_conn(conn, m) {
return sockets.with_conn(conn, fn(c) {
err := _require_mux(m, "http.serve_conn")
if is_error(err) {
return err
}
req := read_request(c)
if is_error(req) {
write_response(c, text(400, inspect(req) + "\n"))
return req
}
resp := dispatch(m, req)
if is_error(resp) {
fallback := _server_error(m, req, resp)
write_response(c, fallback)
return resp
}
return write_response(c, resp)
})
}status_text#
status_text returns a small reason phrase table for common HTTP statuses.
Source lib/http.mu:132
fn status_text(status) {
if status == 100 { return "Continue" }
if status == 200 { return "OK" }
if status == 201 { return "Created" }
if status == 202 { return "Accepted" }
if status == 204 { return "No Content" }
if status == 301 { return "Moved Permanently" }
if status == 302 { return "Found" }
if status == 304 { return "Not Modified" }
if status == 400 { return "Bad Request" }
if status == 401 { return "Unauthorized" }
if status == 403 { return "Forbidden" }
if status == 404 { return "Not Found" }
if status == 405 { return "Method Not Allowed" }
if status == 500 { return "Internal Server Error" }
if status == 502 { return "Bad Gateway" }
if status == 503 { return "Service Unavailable" }
return "Status " + str(status)
}text#
text returns a text/plain response with a UTF-8 content type.
Source lib/http.mu:122
fn text(status, body) {
return new_response(status, {"content-type": "text/plain; charset=utf-8"}, body)
}url#
url converts a tcp:// listener address to a browser-friendly http:// URL.
Source lib/http.mu:712
fn url(addr) {
if !typing.is_str(addr) {
return error("http.url expects string addr, got " + type(addr))
}
parts := strings.split(addr, "://")
if len(parts) == 2 && parts[0] == "tcp" {
return "http://" + parts[1]
}
return addr
}write_request#
write_request writes one HTTP request to a TCP connection.
Source lib/http.mu:241
fn write_request(conn, req) {
raw := format_request(req)
if is_error(raw) {
return raw
}
return sockets.write(conn, raw)
}write_response#
write_response writes one HTTP response to a TCP connection.
Source lib/http.mu:326
fn write_response(conn, resp) {
raw := format_response(resp)
if is_error(raw) {
return raw
}
return sockets.write(conn, raw)
}Internal helpers
Underscore-prefixed names are implementation detail. They are listed so the module's source reads without surprises, not as API — they may change at any time.
| _MAX_HEADER_BYTES | — |
| _READ_CHUNK_SIZE | — |
| _allow_header(allowed) | — |
| _authority_has_port(authority) | — |
| _base_path_from_target(target) | — |
| _body_read_size(have, expected) | — |
| _client_url(c, target) | — |
| _coerce_response(value, caller) | — |
| _format_headers(headers) | — |
| _head_lines(head) | — |
| _header_end(source) | — |
| _index_of_from(source, segment, start) | — |
| _merge_headers(defaults, overrides, caller) | — |
| _method_not_allowed(m, req, allowed) | — |
| _normalize_headers(headers, caller) | — |
| _normalize_method(method, caller) | — |
| _normalize_route_path(path, caller) | — |
| _not_found(m, req) | — |
| _parse_headers(lines, start, caller) | — |
| _parse_status_line(line) | — |
| _read_header_block(conn, caller) | — |
| _read_message(conn, parser, read_until_close, caller) | — |
| _require_client(value, caller) | — |
| _require_mux(value, caller) | — |
| _route_key(method, path) | — |
| _serve_conn_task(conn, m) | — |
| _server_error(m, req, err) | — |
| _split_message(raw, caller) | — |
| _split_target(target) | — |
| _string_or_empty(value, caller, field) | — |