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#

fn client(base_url, defaults...)

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#

fn client_get(c, target)

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#

fn client_post(c, target, body, headers...)

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#

fn client_request(c, method, target, headers, body)

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#

fn close(server)

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#

fn content_length(headers)

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#

fn delete(m, path, handler)

delete registers a DELETE route on m.

Source lib/http.mu:405
fn delete(m, path, handler) {
    return handle(m, "DELETE", path, handler)
}

dispatch#

fn dispatch(m, req)

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#

fn fetch(target_url, opts...)

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#

fn format_request(req)

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#

fn format_response(resp)

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#

fn get(m, path, handler)

get registers a GET route on m.

Source lib/http.mu:385
fn get(m, path, handler) {
    return handle(m, "GET", path, handler)
}

handle#

fn handle(m, method, path, handler)

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#

fn header_get(headers, name)

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#

fn html(status, body)

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#

fn is_response(value)

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#

fn listen(addr, m)

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#

fn 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#

fn new_request(method, target, headers, body)

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#

fn new_response(status, headers, body)

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#

fn normalize_header_name(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#

fn on_error(m, handler)

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#

fn on_method_not_allowed(m, handler)

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#

fn on_not_found(m, handler)

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#

fn parse_request(raw)

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#

fn parse_response(raw)

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#

fn parse_url(value)

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#

fn patch(m, path, handler)

patch registers a PATCH route on m.

Source lib/http.mu:400
fn patch(m, path, handler) {
    return handle(m, "PATCH", path, handler)
}

post#

fn post(m, path, handler)

post registers a POST route on m.

Source lib/http.mu:390
fn post(m, path, handler) {
    return handle(m, "POST", path, handler)
}

put#

fn put(m, path, handler)

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#

fn read_request(conn)

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#

fn read_response(conn)

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#

fn request(method, target_url, headers, body)

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#

fn serve(server)

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#

fn serve_conn(conn, m)

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#

fn status_text(status)

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#

fn text(status, body)

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#

fn url(addr)

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#

fn write_request(conn, req)

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#

fn write_response(conn, resp)

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)