http
HTTP client and server
use httpHTTP over
net’s layer 2 (byte sockets + TLS, §4/net.md), parsing the protocol itself. It does not use the typedChannel[T]: that is the language’s native RPC (both sides shareTat compile time), and HTTP starts from the opposite premise, talking to the world-that-isn’t-yours, whose contract is the wire format (method, headers, status, bytes), language-agnostic. There is noTto put in the channel, so HTTP lives in the bytes. The application surface is version-agnostic (Request/Response/Headers/ Body identical in /1.1, /2, /3); the version lives in the connection, negotiated by ALPN. This package delivers the primitives (types + client + server transport + the handler shapefn(Request) -> Response); router, middleware, and application batteries live in the officialweblib (tier 2), on top of this one.
Version-agnostic surface
Section titled “Version-agnostic surface”The part that does not change between /1.1, /2, and /3. Method, status, headers, body, URL, and the two envelopes (Request/Response).
Method and Status
Section titled “Method and Status”decl Method { Get; Head; Post; Put; Patch; Delete; Connect; Options; Trace }fn (m: Method) to_string() -> string // Display ("GET", "POST", …)fn (Method) parse(s: string) -> Optional[Method] // none if unknown
decl Status { pub code: u16 } // 100..599fn (Status) of(code: u16) -> Statusfn (s: Status) reason() -> string // canonical text ("Not Found")fn (s: Status) is_informational() -> bool // 1xxfn (s: Status) is_success() -> bool // 2xxfn (s: Status) is_redirect() -> bool // 3xxfn (s: Status) is_client_error() -> bool // 4xxfn (s: Status) is_server_error() -> bool // 5xxfn (s: Status) to_string() -> string // Display ("404 Not Found")Named constants for the common statuses (avoids magic numbers at the call site):
const ok: Status // 200const created: Status // 201const accepted: Status // 202const no_content: Status // 204const moved_permanently: Status // 301const found: Status // 302const see_other: Status // 303const not_modified: Status // 304const temporary_redirect: Status // 307const permanent_redirect: Status // 308const bad_request: Status // 400const unauthorized: Status // 401const forbidden: Status // 403const not_found: Status // 404const method_not_allowed: Status // 405const conflict: Status // 409const too_many_requests: Status // 429const internal_error: Status // 500const not_implemented: Status // 501const bad_gateway: Status // 502const service_unavailable: Status // 503Headers: multi-value, case-insensitive name
Section titled “Headers: multi-value, case-insensitive name”Map[string,string] does not work: an HTTP header has a case-insensitive name and is multi-value (several
Set-Cookie, several Accept). Hence the dedicated type:
decl Headers { ... }fn (Headers) new() -> Headers
fn (h: *Headers) set(name: string, value: string) // replaces ALL values for the namefn (h: *Headers) add(name: string, value: string) // appends (preserves the previous ones)fn (h: *Headers) remove(name: string)
fn (h: Headers) get(name: string) -> Optional[string] // 1st value (case-insensitive lookup)fn (h: Headers) get_all(name: string) -> List[string] // all values for the namefn (h: Headers) has(name: string) -> boolfn (h: Headers) names() -> Iterator[string] // names present (canonical form)fn (h: Headers) iter() -> Iterator[(string, string)] // each (name, value) pairfn (h: Headers) len() -> usizeLookup is case-insensitive (h.get("content-type") finds Content-Type); the wire output uses the
header’s canonical capitalization.
Url and Query
Section titled “Url and Query”Parsed URL (the client is the consumer; if another domain needs it, it gets promoted to net later):
decl Url { pub scheme: string // "http" / "https" pub host: string pub port: Optional[u16] // none → scheme default (80/443) pub path: string // "/" when empty pub query: Query pub fragment: Optional[string]}fn (Url) parse(s: string) -> Result[Url, error{Malformed}]fn (u: Url) to_string() -> string // Display (re-encodes percent-encoding)fn (u: Url) with_path(path: string) -> Urlfn (u: Url) with_query(q: Query) -> Url
decl Query { ... } // multi-value and ORDERED (the wire order matters)fn (Query) new() -> Queryfn (Query) parse(s: string) -> Query // "a=1&b=2" → Query (decodes percent-encoding)fn (q: *Query) add(key: string, value: string)fn (q: *Query) set(key: string, value: string)fn (q: Query) get(key: string) -> Optional[string]fn (q: Query) get_all(key: string) -> List[string]fn (q: Query) iter() -> Iterator[(string, string)]fn (q: Query) to_string() -> string // "a=1&b=2" (encodes)Body: streaming by default
Section titled “Body: streaming by default”The body satisfies Readable + Closeable (io): the default is a stream (it does not load everything into
memory, which serves large downloads and SSE), with eager conveniences on top.
decl Body { ... } // Readable + Closeablefn (Body) empty() -> Bodyfn (Body) from_bytes(data: []byte) -> Body // known body (Content-Length)fn (Body) from_string(s: string) -> Bodyfn (Body) from_reader(r: Readable) -> Body // stream of unknown size → chunked
fn (b: *Body) read(buf: mut []byte) -> Result[usize, error{Io, Closed}] // Readable (Ok(0)=end)fn (b: *Body) close() -> error{Io} // Closeable
// conveniences (consume the body; allocate via the context's @mm):fn (b: *Body) read_all() -> Result[[]byte, error{Io, Closed}]fn (b: *Body) to_string() -> Result[string, error{Io, Closed, InvalidUtf8}] // validates UTF-8 at the edge
fn (b: Body) len_hint() -> Optional[usize] // Content-Length if known; none if chunkedRequest and Response
Section titled “Request and Response”decl Request { pub method: Method pub url: Url pub headers: Headers pub body: Body pub version: Version // on the server, the negotiated version; on the client, the transport decides}fn (Request) build(method: Method, url: Url) -> Request // explicit formfn (Request) get(url: string) -> Result[Request, error{Malformed}] // conveniences (parse the URL)fn (Request) post(url: string, body: Body) -> Result[Request, error{Malformed}]fn (r: *Request) with_header(name: string, value: string) -> Request // chainable (returns the adjusted one)fn (r: *Request) with_body(body: Body) -> Request
decl Response { pub status: Status pub headers: Headers pub body: Body pub version: Version}fn (Response) of(status: Status) -> Response // empty response with statusfn (Response) text(status: Status, s: string) -> Response // text body + Content-Type text/plainfn (Response) bytes(status: Status, data: []byte, content_type: string) -> Responsefn (Response) stream(status: Status, r: Readable, content_type: string) -> Response // streamed bodyfn (r: *Response) with_header(name: string, value: string) -> Responsefn (r: *Response) with_body(body: Body) -> Response
decl Version { Http11; Http2; Http3 }fn (v: Version) to_string() -> string // "HTTP/1.1" / "HTTP/2" / "HTTP/3"Client
Section titled “Client”Full by design (pool, redirect, cookie jar, decompression), because an incomplete HTTP client does not serve real
systems. Everything happens inside send; the Client carries the state (connection pool, cookie jar).
decl Client { ... }fn (Client) new() -> Client // sensible defaults (pool/redirect/cookies/gzip on)fn (Client) with(cfg: ClientConfig) -> Client
fn (c: *Client) send(req: Request) -> Result[Response, HttpError] // the central methodfn (c: *Client) get(url: string) -> Result[Response, HttpError] // conveniencesfn (c: *Client) post(url: string, body: Body) -> Result[Response, HttpError]fn (c: *Client) put(url: string, body: Body) -> Result[Response, HttpError]fn (c: *Client) delete(url: string) -> Result[Response, HttpError]Configuration and the client’s closed error-set:
decl ClientConfig { pub timeout: Duration // per request, end to end (default 30s) pub max_redirects: u32 // 0 = do not follow (default 10) pub pool: PoolConfig pub cookies: bool // automatic cookie jar (default true) pub auto_decompress: bool // transparent gzip/deflate on the response (default true) pub tls: TlsConfig // from net: verify, sni, alpn, ca_bundle pub default_headers: Headers // merged into every request (User-Agent, Accept, …) pub https_redirect_only: bool // refuses a redirect that downgrades https→http (default true)}decl PoolConfig { pub max_idle_per_host: u32 // reusable idle connections per host (default 8) pub max_per_host: u32 // cap on simultaneous connections per host (0 = unlimited) pub idle_timeout: Duration // closes the idle one after (default 90s)}
alias HttpError = error{ Malformed, // malformed URL or response Dns, // name resolution failed Connect, // did not connect (refused / unreachable) Tls, // handshake / certificate Timeout, // blew the request timeout TooManyRedirects, // exceeded max_redirects Closed, // the connection closed midway Io}use http
client := http.Client.new()resp := client.get("https://api.exemplo.com/users") catch |e| { return e }if resp.status.is_success() { body := resp.body.to_string() catch |e| { return e } process(body)}Large download without loading everything into memory: the body is Readable, so io.copy pumps it.
resp := client.get("https://exemplo.com/grande.bin") catch |e| { return e }defer resp.body.close() // returns the connection to the poolout := fs.create("grande.bin") catch |e| { return e }io.copy(out, resp.body) catch |e| { return e } // stream → file, in blocksConnection and pool lifecycle: send reuses an idle connection from the pool for the same (scheme, host, port)
or opens a new one; the connection only returns to the pool when the response body is drained or closed, which is why
you should read the body to the end (read_all/to_string) or defer resp.body.close(). Redirects are followed
up to max_redirects, with HTTP semantics (303 becomes GET; 307/308 preserve method and body); a streamed request
body is not replayable, so a redirect that requires resending it becomes Err (use an eager body,
from_bytes/from_string, when you need to survive a redirect). Cookie jar and
decompression are automatic when enabled.
Cookie jar (automatic; exposed for inspection/control):
fn (c: *Client) jar() -> *CookieJardecl CookieJar { ... }fn (j: *CookieJar) add(url: Url, c: Cookie)fn (j: *CookieJar) cookies_for(url: Url) -> List[Cookie]fn (j: *CookieJar) clear()
decl Cookie { pub name: string pub value: string pub domain: string pub path: string pub expires: Optional[DateTime] // from `time`; none = session pub secure: bool pub http_only: bool}Server: one process per stream
Section titled “Server: one process per stream”The handler is just a function fn(Request) -> Response, with no new type:
alias Handler = fn(Request) -> Responsefn serve(addr: string, handler: Handler) -> error{AddrInUse, PermissionDenied, Io} // brings up and blocksfn serve_with(cfg: ServerConfig, handler: Handler) -> error{AddrInUse, PermissionDenied, Io}
// controllable form (graceful shutdown):fn bind(cfg: ServerConfig, handler: Handler) -> Result[Server, error{AddrInUse, PermissionDenied}]decl Server { pub addr: Address }fn (s: *Server) run() -> error{Io} // serves until shutdown (suspends this process)fn (s: *Server) shutdown(grace: Duration) -> error{Io} // stops accepting; drains within 'grace'; closes
decl ServerConfig { pub addr: string pub tls: Optional[TlsConfig] // none = plain text (http/1.1, h2c); some = HTTPS (h2/h3 via ALPN) pub versions: List[Version] // what to offer (default: everything the transport supports) pub read_timeout: Duration // per request pub write_timeout: Duration pub max_header_bytes: usize // cap on headers (anti-abuse) pub max_body_bytes: Optional[usize] // cap on body (none = no cap)}The model is the actor one (§3/§8) extended to HTTP. In /1.1, each connection is a serial stream, and the
server does a spawn of a process running handler(req). In /2 and /3, the connection is multiplexed: an
owner process demultiplexes the frames (HPACK/QPACK, flow control, stream state) and does a spawn
of one process per stream. In any version, the handler runs in its own process: a panic
on one request crashes only that process (the supervisor observes it through catch, §8), never the server, and
the client receives a 500. The response can have a streamed body (Response.stream) for SSE and streaming, without
materializing everything.
use http
fn handle(req: Request) -> Response { match req.method { Get => Response.text(http.ok, "olá, {{req.url.path}}") _ => Response.of(http.method_not_allowed) }}
fn main() { http.serve("0.0.0.0:8080", handle) catch |e| { io.eprintln("falha ao subir: {{e}}") os.exit(1) }}Graceful shutdown pairs with signals-as-a-channel (os.signals, §4): run the server in a process and
ask for shutdown when the signal arrives.
srv := http.bind(cfg, handle) catch |e| { os.exit(1) }spawn srv.run()sigs := os.signals(.Interrupt, .Terminate)loop s in sigs { match s { Interrupt | Terminate => { srv.shutdown(10s); break } _ => {} }}Transport layer: where the version lives
Section titled “Transport layer: where the version lives”The surface above is identical across the three versions; the difference lives here, and each version sits on net
(layer 2) in its own way. The version is negotiated, not chosen per call.
- HTTP/1.1, over
TcpStream(orTlsStream), §net. One request/response at a time on the connection;keep-alivereuses the connection serially; a body of unknown size goes chunked. The pool keeps idle connections (many connections per host for concurrency). It is the complete cut of the first release. - HTTP/2, over a single
TlsStream(ALPN negotiates"h2") or plain texth2c. One connection multiplexes many streams: the owner process runs the frame layer (HPACK, flow control) and does aspawnper stream. This refines the actor model, process-per-stream instead of process-per-connection, without breaking it; and the pool now keeps one connection per host with many concurrent streams (instead of the many connections of /1.1). - HTTP/3, over QUIC (UDP, via
net’sudp_bind/datagrams), with TLS 1.3 built into the transport, its own multiplexing (no TCP head-of-line blocking) and 0-RTT. ALPN"h3"+Alt-Svcannounces availability. The application surface does not change; what is big is the implementation: a QUIC stack (congestion control, loss recovery, multiplexing over UDP) is an entire subsystem, delivered as the lib matures. The commitment is locked in by the already version-agnostic API; the weight is the stack.
How the version is decided. On the client, ALPN during TLS negotiates the highest mutually
supported version (h2/h3 if offered, otherwise http/1.1); h3 additionally depends on discovery via
Alt-Svc or opt-in. On the server, ServerConfig.versions lists what is offered, and ALPN selects per
connection. There is no per-request version button: the surface is agnostic, the connection resolves it. (Forcing
a specific version, for testing, is a future direction via ClientConfig.)
Curation
Section titled “Curation”- Tier 1, over
net’s layer 2. HTTP talks to the world-that-isn’t-yours via bytes + TLS, not through the typedChannel[T]. The typed channel is the native RPC (both sides shareT); HTTP is language-agnostic, there is noT. It is a category difference, not a limitation, and that is why the lib parses the protocol itself overTcpStream/TlsStream/QUIC. - Version-agnostic surface (like Go’s
net/http): Request/Response/Headers/Body identical in /1.1, /2, /3; the version lives in the connection, negotiated by ALPN. No version per call. - Process per stream, not per connection. Actor model (§3/§8) extended: /1.1 uses process per connection;
/2-3 uses process per multiplexed stream. A
panicon one request crashes only its process; the server goes on. It is the language’s fault tolerance applied to HTTP, for free. - The framework is
web(tier 2), separate.httpis primitive (types + client + server transport + thefn(Request) -> Responseshape); router, middleware, and app batteries go up inweb, and the UI (§20) goes up inweb. Whoever importshttpwants to build; whoever wants batteries importsweb. That is why HTTP ended up non-core: it is, itself, a layered stack. - Full client (pool/redirect/cookie/decompression) because it aims at services/systems, where an incomplete
client is useless. Everything inside
Client.send; the state (pool, jar) lives in theClient. - Streaming body by default:
BodysatisfiesReadable+Closeable;read_all/to_stringare conveniences. Large download and SSE without materializing everything. - Colorless async (§12): no function is
async; the process suspends on IO; parallelism isspawnper stream. NoFuture/await/context.Context; cancellation istimeoutor process death (§8). /3committed, implementation deferred. The already-agnostic surface lets QUIC enter without trauma; the QUIC stack is the real weight, delivered as the lib matures.- Decompression uses codecs from
encoding.auto_decompressleans on DEFLATE/gzip (frozen formats, candidates forencoding/compressas brotli/zstd come in);httponly orchestrates them, the same role thatnet/TLS plays withcrypto. The lib does not reimplement compression. - No allocator in the signatures (colorless); streaming buffers allocate via the context’s
@mm, or you passmut []bytewhere you want control (theread(buf)port ofReadable). Urllives here (the consumer is the client); it gets promoted tonetif another domain comes to need it.