Networking in Iron

Build bounded HTTP/HTTPS clients, JSON APIs, webpages, WebSocket/WSS services, and low-level transports with explicit errors, deadlines, and concurrency.

import http

HTTP/1.1 client and server models, JSON and HTML responses, file serving, and safe message framing.

import websocket

RFC 6455 clients and server upgrades over ws:// and verified wss://.

import net

TCP, UDP, typed IPv4/IPv6 addresses, DNS resolution, and millisecond timeout budgets.

import io

Bounded text and binary reads, writes, append, metadata, copy, and move.

Operational model: networking calls are synchronous and bounded. Compose them with spawn for concurrency; the HTTP layer never creates hidden handler threads.

Result ownership and release

HTTP, WebSocket, and explicit file result values own their returned string fields. Release every result after its last read, on success and error paths. Resource handles have a separate lifetime: close or transfer the server, connection, client, or socket first, then release the result model.

val response = Http.get(url, 5000)
if response.error == 0 { println(response.body) }
HttpResponse.release(response)

Consuming operation: Iron strings and models are value types. Never use a released value or a by-value alias afterwards. Response constructors clone caller inputs, so releasing a response does not invalidate the original body or headers. C callers use iron_string_release and the public Iron_*_release helpers.

Your first HTTP client

Http.get returns an HttpResponse value. A zero error means transport and HTTP framing succeeded; application status remains in status.

import http

func main() {
    val response = Http.get(
        "http://127.0.0.1:8080/api/status",
        5000,
    )
    if response.error != 0 {
        println("request failed: {response.error} {response.error_message}")
    }
    if response.error == 0 {
        println("HTTP {response.status} {response.reason}")
        println(response.body)
    }
    HttpResponse.release(response)
}
iron build status_client.iron -o status-client
./status-client

Errors, status codes, and timeouts

HTTP client requests

Post JSON

Iron uses braces for string interpolation, so literal JSON braces must be escaped.

val response = Http.post_json(
    "http://127.0.0.1:8080/api/items",
    "\{\"name\":\"anvil\"\}",
    5000,
)
-- use response, then consume its owned strings
HttpResponse.release(response)

Custom methods and headers

val response = Http.request(
    "PUT",
    "http://127.0.0.1:8080/api/items/42",
    "Authorization: Bearer dev-token\r\nContent-Type: application/json",
    "\{\"name\":\"hammer\"\}",
    1048576,  -- maximum decoded response body
    5000,     -- complete request budget
)
HttpResponse.release(response)

The client owns Host, Connection, Content-Length, and Transfer-Encoding. Supplying any of them in the custom block is rejected to prevent ambiguous framing.

HTTPS and certificates

The regular client calls accept https://. Certificate-chain and hostname/IP verification are on by default, system roots are loaded automatically, DNS names receive SNI, and TLS 1.2 is the minimum.

Secure backend: source builds enable TLS when CMake finds the OpenSSL development headers and libraries. Linux and macOS CI require verified TLS and test HTTPS compilation with a cleanly installed compiler, including Homebrew's keg-only OpenSSL. Without a development installation, HTTP/WS and plain sockets still work; HTTPS/WSS return typed error 3000 instead of falling back to plaintext.

val public_api = Http.get(
    "https://example.com/api/status", 5000,
)

val private_ca = Http.get_with_ca(
    "https://localhost:8443/", "cert.pem", 5000,
)

val created = Http.request_with_ca(
    "POST", "https://localhost:8443/api/items",
    "Content-Type: application/json", "\{\"name\":\"anvil\"\}",
    1048576, "cert.pem", 5000,
)
HttpResponse.release(public_api)
HttpResponse.release(private_ca)
HttpResponse.release(created)

Http.get_insecure and Http.request_insecure are explicitly unsafe development escape hatches. Never use them with production credentials.

val listening = Http.listen_tls(
    "127.0.0.1", 8443, "cert.pem", "key.pem",
)
val pending = HttpsServer.accept_tcp(listening.server, 60000)
if pending.error == 0 {
    val connection = pending.connection
    HttpsPendingConnectionResult.release(pending)
    spawn("tls-client") {
        val secure = HttpsPendingConnection.handshake(connection, 5000)
        if secure.error != 0 {
            val error = secure.error
            HttpsConnectionResult.release(secure)
            return error
        }
        val request = HttpsConnection.read_request(
            secure.connection, 16384, 1048576, 5000,
        )
        val error = request.error
        HttpRequest.release(request)
        HttpsConnection.close(secure.connection)
        HttpsConnectionResult.release(secure)
        return error
    }
} else {
    HttpsPendingConnectionResult.release(pending)
}
HttpsServer.close(listening.server)
HttpsServerResult.release(listening)

accept_tcp is bounded only by the listener deadline. Each spawned handler gets a separate TLS handshake deadline, so a raw client that sends no ClientHello cannot block later clients. Handshake consumes the pending connection on either outcome; call HttpsPendingConnection.close when abandoning it. The one-step HttpsServer.accept remains available for controlled servers.

Certificate files: the certificate PEM may contain the leaf followed by intermediates. The private key must match. See the compiler-tested HTTPS server and private-CA REST client examples.

Certificates are loaded when listen_tls creates its server context. Rotate them by starting a replacement context or restarting with the new PEM files, then drain the old listener. For graceful shutdown, stop accepting, close the server, await bound handler tasks, and close their remaining connections. Keep task handles when draining matters; intentionally detached handlers cannot be awaited.

Persistent HTTP clients and servers

One-shot calls remain connection-closing conveniences. For chatty traffic, HttpClient explicitly owns a same-origin bounded pool, including the HTTPS verification configuration.

val opened = HttpClient.open(
    "https://api.example.com", "", false, 4, 30000,
)
val first = HttpClient.request(
    opened.client, "GET", "/api/status", "", "", 1048576, 5000,
)
val second = HttpClient.request(
    opened.client, "GET", "/api/items", "", "", 1048576, 5000,
)
HttpClient.close(opened.client)
HttpResponse.release(first)
HttpResponse.release(second)
HttpClientResult.release(opened)

The origin locks scheme, host, port, trust roots, and verification mode. It accepts only an authority with an optional trailing slash; paths, queries, fragments, and TLS-only options on plain HTTP are rejected rather than silently ignored. The connection limit bounds concurrent sockets; the idle timeout retires old entries. A stale socket is replayed once only for bodyless GET or HEAD—potentially non-idempotent requests are never retried automatically.

Server bounds: loop over read_request with an idle deadline and maximum request count, then pass request.keep_alive to send_response_keep_alive. Responses always carry Content-Length. HTTP pipelining is intentionally unsupported; graceful shutdown closes the listener first and then drains bounded handlers.

A concurrent REST server

This complete server exposes a webpage, a JSON status endpoint, and a JSON POST endpoint. Each accepted connection is handled by an intentional detached spawn statement.

import http

func handle(connection: HttpConnection) -> Int {
    val request = HttpConnection.read_request(
        connection, 65536, 1048576, 5000,
    )
    if request.error != 0 {
        val bad = Http.text_response(400, "bad request")
        val sent = HttpConnection.send_response(connection, bad, 5000)
        HttpResponse.release(bad)
        HttpRequest.release(request)
        HttpConnection.close(connection)
        return sent
    }

    if request.method == "GET" and request.path == "/" {
        val page = Http.html_response(200, "<h1>Hello from Iron</h1>")
        val sent = HttpConnection.send_response(connection, page, 5000)
        HttpResponse.release(page)
        HttpRequest.release(request)
        HttpConnection.close(connection)
        return sent
    }

    if request.method == "GET" and request.path == "/api/status" {
        val response = Http.json_response(200, "\{\"status\":\"ok\"\}")
        val sent = HttpConnection.send_response(connection, response, 5000)
        HttpResponse.release(response)
        HttpRequest.release(request)
        HttpConnection.close(connection)
        return sent
    }

    if request.method == "POST" and request.path == "/api/items" {
        val response = Http.json_response(201, "\{\"created\":true\}")
        val sent = HttpConnection.send_response(connection, response, 5000)
        HttpResponse.release(response)
        HttpRequest.release(request)
        HttpConnection.close(connection)
        return sent
    }

    val missing = Http.json_response(404, "\{\"error\":\"not found\"\}")
    val sent = HttpConnection.send_response(connection, missing, 5000)
    HttpResponse.release(missing)
    HttpRequest.release(request)
    HttpConnection.close(connection)
    return sent
}

func main() {
    val listening = Http.listen("127.0.0.1", 8080)
    if listening.error != 0 {
        println("listen failed: {listening.error_message}")
        HttpServerResult.release(listening)
        return
    }
    val server = listening.server
    HttpServerResult.release(listening)

    while true {
        val accepted = HttpServer.accept(server, 60000)
        if accepted.error == 0 {
            val connection = accepted.connection
            HttpConnectionResult.release(accepted)
            spawn("http-request") {
                return handle(connection)
            }
        } else {
            HttpConnectionResult.release(accepted)
        }
    }
}

The repository contains the maintained, compiler-tested version at examples/networking/rest_server.iron.

Read routes, headers, query strings, and bodies

Request fieldMeaning
methodValidated HTTP method token such as GET or POST.
targetOriginal path and query target.
pathPath without the query string.
queryRaw query text without the leading question mark.
headersValidated CRLF-separated header lines. Use Http.header for case-insensitive lookup.
bodyDecoded fixed-length or chunked request body, bounded by max_body_bytes.

Serve webpages and files

val page = Http.html_response(
    200,
    "<!doctype html><h1>Iron is online</h1>",
)
-- send page, then release it
HttpResponse.release(page)
val asset = Http.file_response(
    200,
    "public/index.html",
    "text/html; charset=utf-8",
    1048576,
)
-- send asset, then release it
HttpResponse.release(asset)

file_response reads at most the supplied limit. Choose the content type explicitly; Iron does not guess it from the filename.

Text and binary file operations

Iron strings preserve embedded zero and arbitrary byte values. The byte APIs therefore carry exact binary payloads without a second buffer type.

import io

val written = IO.write_bytes("asset.bin", "Iron\0binary")
val loaded = IO.read_bytes("asset.bin", 1048576)
if loaded.error == 0 {
    val info = IO.file_info("asset.bin")
    println("loaded {info.size} bytes")
    FileInfo.release(info)
}

val appended = IO.append_bytes("asset.bin", "\0suffix")
val copied = IO.copy_file("asset.bin", "asset-copy.bin", false)
val moved = IO.move_file("asset-copy.bin", "archive.bin", false)
FileWriteResult.release(written)
FileReadResult.release(loaded)
FileWriteResult.release(appended)
FileWriteResult.release(copied)
FileWriteResult.release(moved)

read_text and read_bytes reject an oversized file before allocating. Write and append operations report committed bytes and expose open, write, flush, and close failures. copy_file and move_file require an explicit overwrite choice. With overwrite=false, move uses an atomic no-replace commit: a concurrently created destination wins intact and the source remains. Unsupported filesystems return an error instead of using a racy check-then-rename.

WebSocket and secure WebSocket

import websocket supports RFC 6455 clients and HTTP/HTTPS server upgrades. WSS uses the same verified TLS policy as HTTPS.

val connected = WebSocket.connect_with_protocols(
    "wss://events.example.com/graphql",
    "Authorization: Bearer token",
    ["graphql-transport-ws", "graphql-ws"],
    1048576, 5000,
)
-- use/close connected.socket, then consume the result strings
WebSocketResult.release(connected)

Requested protocols preserve preference order. A server uses upgrade_websocket_protocol to select zero or one exact offered token; the negotiated value is returned as connected.protocol. Unsolicited or multiple selections fail the handshake. Raw Sec-WebSocket-* overrides remain forbidden, and extensions are never negotiated until Iron implements their frame semantics.

import websocket

val connected = WebSocket.connect(
    "wss://events.example.com/v1",
    "Authorization: Bearer token",
    1048576, 5000,
)
if connected.error == 0 {
    val sent = WebSocket.send_text(connected.socket, "subscribe", 5000)
    val message = WebSocket.receive(connected.socket, 30000)
    val closed = WebSocket.close(connected.socket, 1000, "done", 5000)
    WebSocketMessage.release(message)
}
WebSocketResult.release(connected)

Message kinds are 1 text, 2 binary, 8 close, 9 ping, and 10 pong. Ping is answered automatically. Fragmented messages are reassembled under the connection limit; text and close reasons are UTF-8 validated.

val request = HttpConnection.read_request(connection, 16384, 1024, 5000)
val upgraded = HttpConnection.upgrade_websocket(
    connection, request, 1048576, 5000,
)
HttpRequest.release(request)
-- use/close upgraded.socket, then release the model
WebSocketResult.release(upgraded)

Ownership: a successful upgrade transfers the HTTP connection to the WebSocket. Use one receiving task and any number of sending tasks; frame writes are serialized. Do not race close or abort with another operation.

Concurrency and ownership

A standalone spawn statement is intentionally detached and uses the selected runtime pool. Bind it when the parent must await completion or collect a result.

spawn("connection") {
    return handle(accepted.connection)
}
val first = spawn("client-1") { return Http.get(url1, 5000) }
val second = spawn("client-2") { return Http.get(url2, 5000) }

val a = await first
val b = await second
HttpResponse.release(a)
HttpResponse.release(b)

Message limits and safe framing

Low-level TCP

TcpSocket.read returns up to the caller-selected limit as an immutable, binary-safe String. Embedded zero bytes are preserved.

import net

val (socket, dial_error) = Net.tcp_dial("127.0.0.1", 9000, 2000)
if dial_error.code != 0 { return }

val (written, write_error) = TcpSocket.write(socket, "ping", 1000)
val (payload, read_error) = TcpSocket.read(socket, 65536, 1000)
if read_error.code == 0 {
    println(payload)
}
TcpSocket.close(socket)

TCP is a byte stream: one write does not guarantee one read. Loop until your protocol delimiter or expected length is complete, and treat an empty successful read as EOF.

UDP

Bind datagram sockets, send to typed IPv4 or IPv6 addresses, and receive a bounded UdpPacket containing the binary-safe payload plus the sender's numeric address and port.

import net

val (receiver, bind_error) = Net.udp_bind("127.0.0.1", 5353)
val (sender, sender_error) = Net.udp_bind("127.0.0.1", 0)
val (target, parse_error) = IPv4Addr.parse("127.0.0.1")
val (sent, send_error) = Net.udp_sendto_v4(sender, "ping", target, 5353, 1000)

val packet = UdpSocket.recvfrom(receiver, 65536, 1000)
if packet.error.code == 0 {
    println("{packet.address}:{packet.port} {packet.data}")
}
UdpSocket.close(sender)
UdpSocket.close(receiver)

Datagram boundaries are preserved: one successful receive consumes one packet. When a packet is larger than max_bytes, truncated is 1, data contains the captured prefix, and error.code is 1018. A zero-length packet is still successful; a timeout uses code 1004.

DNS and typed addresses

val (addresses, error) = Net.lookup_host("example.com", 2000)
if error.code != 0 {
    println("DNS failed: {error.code}")
    return
}

for address in addresses {
    match address {
        Address.V4(v4) -> println(IPv4Addr.format(v4))
        Address.V6(v6) -> println(IPv6Addr.format(v6))
    }
}

HTTP API summary

APIUse
Http.get(url, timeout)Bounded GET convenience call.
Http.post_json(url, body, timeout)POST with JSON content type.
Http.request(...)Custom method, headers, body, body limit, and timeout.
Http.request_with_ca(...)Custom verified HTTPS request with a private trust root.
Http.request_insecure(...)Explicitly unsafe custom request for local development only.
Http.listen(host, port)Create a TCP-backed HTTP server.
Http.listen_tls(...)Create a TLS-backed HTTPS server from a PEM chain and key.
Http.get_with_ca(...)Verified HTTPS GET with a private/test trust root.
HttpServer.accept(server, timeout)Accept one connection.
HttpConnection.read_request(...)Parse one bounded HTTP/1.1 request.
HttpConnection.send_response(...)Send a complete response with owned framing.
Http.json_responseJSON response constructor.
Http.html_responseUTF-8 HTML response constructor.
Http.file_responseBounded file-backed response.
Http.header(headers, name)Case-insensitive header lookup.
WebSocket.connect(...)Open a ws:// or verified wss:// client.
upgrade_websocket(...)Validate an HTTP upgrade and transfer connection ownership.
WebSocket.send_text/send_bytesSend a serialized text or binary frame.
WebSocket.receiveReceive a bounded message or control frame.
IO.read_bytes/write_bytesBounded, exact binary file I/O.
IO.file_info/copy_file/move_fileMetadata and explicit-overwrite file operations.

Production checklist

  1. Bind deliberately: loopback for local tools, a public interface only when intended.
  2. Set finite accept, header, body, handler, and upstream timeouts.
  3. For public HTTPS/WSS listeners, use accept_tcp, then run each bounded TLS handshake in its handler task.
  4. Set endpoint-specific body limits instead of accepting the convenience maximum everywhere.
  5. Check both transport errors and application status codes.
  6. Close every accepted connection on every handler path.
  7. Use HTTPS/WSS, keep certificate verification enabled, and protect private keys.
  8. Apply authentication, authorization, rate limiting, logging, and graceful shutdown at the application boundary.

Current implementation status

● Verified remotely TCP, UDP, IP, DNS, HTTP/HTTPS clients and servers, REST JSON, webpage serving, WebSocket/WSS (including one secure reader with 24 secure writers), exact binary files, and concurrent spawn/await composition pass the Linux x86_64 Debug and Release suites.

Platform note: verified TLS and installed-compiler HTTPS builds run in Linux and macOS CI. The reproducible silvaserver.local validation image includes the matching Clang 14 ASan/UBSan/TSan runtimes and OpenSSL development files; its focused networking, concurrency, and file battery passes 8/8 under ASan/UBSan with LeakSanitizer enabled and 8/8 under TSan. CMake fails incomplete sanitizer toolchains during configuration. Native Windows is not yet a supported Iron compiler target; use WSL there. Without OpenSSL development headers, plain networking remains available and secure calls return a typed TLS-unavailable error.

See the maintained validation matrix and networking examples for source-level evidence.