This page is generated from docs/networking.md on every deploy, and every Iron example in it is compiled and run in CI. A plain Markdown copy for tools and language models is at /llms/networking.md; see /llms.txt for the full index.

Networking

Iron ships synchronous, deadline-bounded networking layers:

This guide is published at ironlang.dev/networking, generated from this file; every Iron example in it is compiled in CI. The runnable programs live in examples/networking.

Timeouts are integer milliseconds. A timeout is a single monotonic budget for the complete operation, including connect, TLS handshake, all partial writes, header parsing, and body/frame decoding. Transport errors use codes 1000..1999, TLS uses 3000..3099, HTTP uses 5000..5099, WebSocket uses 6000..6099, and file I/O uses 8000..8099.

Your first HTTP client

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)
    }
}

Build and run it against any local service:

iron build status_client.iron -o status-client
./status-client

Reading the result:

Result ownership

HTTP, WebSocket, and explicit file operations return model values that own their string fields. Those strings are freed with the value that holds them, like any other Iron string: nothing needs to be released by hand.

The release helpers (HttpRequest.release, HttpResponse.release, WebSocketMessage.release, FileReadResult.release, value.release() on a string, and the other *Result.release functions) remain for source compatibility and do nothing.

Resource handles have a separate lifetime: close or transfer the server, connection, client, or socket when you are done with it. Response constructors clone their header and body inputs. Low-level iron_tls.h result structs contain only resource handles and numeric Iron_NetError values.

Client requests

For JSON POST requests, use Http.post_json. Literal braces must be escaped because unescaped {...} is Iron string interpolation:

val response = Http.post_json(
    "http://127.0.0.1:8080/api/items",
    "\{\"name\":\"anvil\"\}",
    5000,
)

Http.request(method, url, headers, body, max_body_bytes, timeout) is the bounded general form:

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
)

User headers are raw Name: value lines separated by CRLF, written \r\n in an Iron string. Host, Connection, Content-Length, and Transfer-Encoding are owned by the client and rejected in the user block to avoid ambiguous message framing.

Server, REST, and webpages

The server lifecycle is explicit:

Http.listen -> HttpServer.accept -> HttpConnection.read_request
            -> HttpConnection.send_response -> HttpConnection.close

A handler reads one bounded request, answers it and closes the connection; the server spawns one handler per accepted connection:

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)
        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)
        HttpConnection.close(connection)
        return sent
    }
    val missing = Http.json_response(404, "\{\"error\":\"not found\"\}")
    val sent = HttpConnection.send_response(connection, missing, 5000)
    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}")
        return
    }
    var served = 0
    while served < 100 {
        val accepted = HttpServer.accept(listening.server, 60000)
        if accepted.error == 0 {
            spawn("connection") {
                return handle(accepted.connection)
            }
            served += 1
        }
    }
    HttpServer.close(listening.server)
}

HttpRequest exposes the parsed request:

Field Meaning
method Validated HTTP method token such as GET or POST.
target The original path and query.
path The path without the query string.
query The raw query text without the leading question mark.
version HTTP/1.1.
headers Validated CRLF-separated header lines; Http.header(headers, name) looks one up case-insensitively.
body The decoded fixed-length or chunked body, bounded by max_body_bytes.

REST and page responses are built with:

See rest_server.iron for a runnable webpage plus GET /api/status and POST /api/items service.

HTTPS

Http.get, Http.request, and Http.post_json accept both http:// and https://. HTTPS verifies the certificate chain and hostname or IP address, uses system trust roots, sends SNI for DNS names, requires TLS 1.2 or newer, and shares one deadline across TCP connect, TLS handshake, request, and response.

The source build enables the secure backend when CMake finds the OpenSSL development headers and libraries. Without them, HTTP/WS and all plain socket features still work; HTTPS/WSS calls return typed error 3000 (IRON_ERR_TLS_UNAVAILABLE) instead of silently using plaintext. Linux and macOS CI install OpenSSL explicitly, require the verified TLS test, and compile an HTTPS program with a cleanly installed ironc. CMake passes the exact include and library paths it validated to that compiler, including Homebrew's keg-only OpenSSL location.

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

Private PKI and local development certificates can be supplied explicitly for GET or any custom REST method:

val response = 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,
)

Http.get_insecure and Http.request_insecure are explicitly unsafe development escape hatches for local development only. They must not be used with production credentials. Serve HTTPS with Http.listen_tls(host, port, certificate_chain_pem, private_key_pem), then use the HttpsServer and HttpsConnection methods. See https_server.iron. The matching private-CA REST client is https_client.iron.

Production HTTPS and WSS accept loops should separate TCP admission from TLS:

func accept_one(server: HttpsServer) {
    val pending = HttpsServer.accept_tcp(server, 60000)
    if pending.error == 0 {
        val connection = pending.connection
        spawn("tls-client") {
            val secure = HttpsPendingConnection.handshake(connection, 5000)
            val outcome = secure.error
            if secure.error == 0 {
                -- read HTTP or upgrade to WebSocket here
                HttpsConnection.close(secure.connection)
            }
            return outcome
        }
    }
}

The accept deadline applies only while waiting for a TCP client. Each spawned handler has an independent handshake deadline, so a raw client that sends no TLS ClientHello cannot hold up later clients. handshake consumes the pending connection on success or failure; call HttpsPendingConnection.close instead when abandoning it. Pending and established connections safely retain the TLS certificate context while an old listener is being drained. The one-step HttpsServer.accept API remains available for simple, controlled servers.

The certificate and key are loaded into the server context at listen time. There is no in-place hot reload: rotate certificates by starting a new server context (or restarting the service) with the replacement files, then drain the old listener. For graceful shutdown, stop scheduling accepts, close the HttpServer/HttpsServer to wake or prevent new work, await all bound handler tasks, and close their remaining connections. Detached handlers cannot be awaited, so production servers that require draining should retain task handles.

WebSocket and secure WebSocket

Connect with a message allocation limit and one deadline:

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)
    if message.error == 0 and message.kind == 1 {
        println(message.data)
    }
    val closed = WebSocket.close(connected.socket, 1000, "done", 5000)
}

Use connect_with_ca for a private root. The explicitly named connect_insecure variant is development-only. WebSocketMessage.kind is 1 for text, 2 for binary, 8 for close, 9 for ping, and 10 for pong. Ping is answered automatically before it is returned. Fragmented messages are reassembled and checked against max_message_bytes; text and close reasons are UTF-8 validated.

Services such as GraphQL or MQTT can request subprotocols in preference order without overriding handshake-owned headers:

val connected = WebSocket.connect_with_protocols(
    "wss://events.example.com/graphql",
    "Authorization: Bearer token",
    ["graphql-transport-ws", "graphql-ws"],
    1048576,
    5000,
)
if connected.error == 0 {
    println("selected {connected.protocol}")
}

Servers use upgrade_websocket_protocol(..., selected_protocol, ...) with exactly one protocol offered by the request, or use the original upgrade call to select none. The client rejects unsolicited or multiple server selections. Raw Sec-WebSocket-Protocol and other handshake-owned header overrides remain forbidden. Extension offers are never accepted silently: Iron currently emits no Sec-WebSocket-Extensions response and rejects any extension selected by a peer because no extension frame semantics are implemented.

On the server, first read the HTTP request and then transfer connection ownership with HttpConnection.upgrade_websocket or HttpsConnection.upgrade_websocket. A successful upgrade owns the original connection, so do not close it separately. See websocket_echo_server.iron and websocket_echo_secure_server.iron.

One task may receive from a socket while multiple tasks send; complete frame writes are serialized. There must be only one receiving task, and close/abort must not race other operations.

A receive that fails with a framing error (6002, after sending close 1002), a message over the limit (6003, close 1009), invalid UTF-8 (6004, close 1007) or a stream that ends inside a frame (6005) fails the connection: later receives and sends return 6005, because the stream is no longer at a frame boundary. Nothing is sent after a close frame. close stops sending after the closing handshake and drains what the peer still sends (at most a second) before closing, so the peer reads the close frame instead of a reset. After close or abort, every copy of the WebSocket value is stale: is_open is false and the other calls return 6005. is_open is also false once a close frame was received or a receive failed.

Text and binary file operations

Iron String values preserve embedded zero and arbitrary bytes. The byte APIs therefore use String without losing binary data:

import io

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

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)

Available result-returning operations are read_text, read_bytes, write_text, write_bytes, append_text, append_bytes, file_info, copy_file, and move_file. Reads reject a file larger than the caller's limit before allocating. Writes report the committed byte count and surface open, write, flush, and close errors. Copy/move take an explicit overwrite flag. See file_operations.iron. With overwrite=false, move commits the destination atomically: if another task or process creates that path first, the move returns IRON_ERR_IO_ALREADY_EXISTS and preserves both the source and the winning destination. POSIX filesystems use an atomic hard-link commit (including after a cross-device temporary copy); Windows uses MoveFileEx without replacement. Filesystems that cannot provide that primitive return an error instead of falling back to a racy check-then-rename.

Persistent HTTP connections

One-shot calls such as Http.get remain the simplest safe option and close their connection. For chatty same-origin traffic, an explicit HttpClient owns a bounded pool:

val opened = HttpClient.open(
    "https://api.example.com", "", false, 4, 30000)
if opened.error != 0 {
    return
}

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)

The origin fixes scheme, host, port, certificate roots, and verification mode; it must not contain a path, query, or fragment (an optional trailing slash is accepted). TLS-only CA and insecure options are rejected for plain HTTP rather than ignored. Requests accept only origin-form targets beginning with /. max_connections bounds concurrent sockets and idle_timeout retires old idle entries. A stale reused connection is retried once only for bodyless GET or HEAD. POST and other potentially non-idempotent requests are never replayed automatically.

Servers opt into persistence per response. Use request.keep_alive (which implements HTTP/1.1 and HTTP/1.0 Connection-token rules), an idle timeout on each next read_request, and an application request-count limit:

func serve_session(connection: HttpConnection) {
    var served = 0
    var running = true
    while running and served < 100 {
        val request = HttpConnection.read_request(connection, 16384, 1048576, 15000)
        if request.error != 0 { running = false }
        if request.error == 0 {
            served += 1
            val keep = request.keep_alive and served < 100
            val response = Http.text_response(200, "ok")
            val sent = HttpConnection.send_response_keep_alive(
                connection, response, keep, 5000)
            if sent != 0 { running = false }
            if sent == 0 { running = keep }
        }
    }
    HttpConnection.close(connection)
}

Every persistent response uses Content-Length, so the next message boundary is unambiguous. Pipelining is intentionally unsupported: send the next request only after reading the prior response. For graceful shutdown, close the listener, stop spawning sessions, let bounded handlers finish, then close the client/session handles. See http_keep_alive.iron.

Framing and limits

TCP

Net.tcp_dial connects with a deadline; TcpSocket.read returns up to the caller's limit as a 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 the protocol's delimiter or expected length is complete, and treat an empty successful read as end of stream. TcpSocket.write writes the whole buffer within its deadline (on a timeout or error the count is what was written before it); a read returns what one receive gives, never more than max_bytes (a large limit does not allocate a large buffer). Ports outside 0..65535 are invalid arguments (1024), never clamped or wrapped.

UDP

Bind datagram sockets, send to typed IPv4 or IPv6 addresses, and receive a bounded UdpPacket with the binary-safe payload and 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. If a datagram exceeds max_bytes, the captured prefix is returned with truncated == 1 and error code 1018. A successful zero-length datagram has empty data and error code 0; a timeout has code 1004. Sending a datagram larger than the system allows is error 1018 too (65507 bytes over IPv4; macOS limits UDP sends to 9216 bytes by default, net.inet.udp.maxdgram).

DNS and 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))
    }
}

IPv4Addr.parse, IPv6Addr.parse and the matching format functions convert between text and the typed addresses the socket calls take.

An address literal resolves to itself without a resolver query. A name that is empty or holds a zero byte is error 1009 (it is never looked up as the text before the zero byte).

Concurrency

A standalone spawn statement is detached: the handler runs to completion on its own, as a task on the runtime's worker pool (one thread per CPU core but one). A handler that keeps a connection open holds its worker for as long as the client does, so serve keep-alive clients through Http.poller (next section) rather than a handler loop per connection. Bind a spawn when the parent must await completion or collect a result; a bound spawn runs on a thread of its own:

val url1 = "http://127.0.0.1:8080/api/status"
val url2 = "http://127.0.0.1:8080/api/items"
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
println("{a.status} {b.status}")

Every accepted connection has an owner and must be closed on every handler path.

Many keep-alive clients: Http.poller

Http.poller(server, idle_timeout) keeps idle connections away from the workers. It accepts from the listener itself (non-blocking sockets with TCP_NODELAY) and watches new and parked connections with epoll, kqueue or poll; next(timeout) returns a connection only once a request has started to arrive on it. The worker reads and answers that one request, then gives the connection back with park (keep-alive) or discard (close). Connections waiting for their first request or parked between requests are closed after idle_timeout milliseconds.

func serve_one(poller: HttpPoller, connection: HttpConnection) -> Int {
    val request = HttpConnection.read_request(connection, 16384, 1048576, 5000)
    if request.error != 0 {
        poller.discard(connection)
        return 0
    }
    val sent = HttpConnection.send_response_keep_alive(
        connection, Http.json_response(200, "\{\"ok\":true}"), request.keep_alive, 5000)
    if sent == 0 and request.keep_alive {
        poller.park(connection)
    } else {
        poller.discard(connection)
    }
    return 0
}

func main() {
    val listening = Http.listen("127.0.0.1", 8080)
    if listening.error != 0 {
        return
    }
    val poller = Http.poller(listening.server, 30000)
    var served = 0
    while served < 1000 {
        val ready = poller.next(1000)
        if ready.error == 0 {
            val connection = ready.connection
            spawn("request") {
                return serve_one(poller, connection)
            }
            served += 1
        }
    }
    poller.close()
    HttpServer.close(listening.server)
}

Any number of threads may call next at once, and each takes one ready connection. The fastest way to serve is a few serving threads (bound spawns, so they do not occupy the task pool) that each serve the connection they take in place, instead of handing every request to a task:

func serve_one(poller: HttpPoller, connection: HttpConnection) {
    val request = HttpConnection.read_request(connection, 16384, 1048576, 5000)
    if request.error == 0 and HttpConnection.send_response_keep_alive(
            connection, Http.text_response(200, "ok"), request.keep_alive, 5000) == 0 and
            request.keep_alive {
        poller.park(connection)
    } else {
        poller.discard(connection)
    }
}

func serve_loop(poller: HttpPoller) -> Int {
    var served = 0
    while true {
        val ready = poller.next(1000)
        if ready.error == 0 {
            serve_one(poller, ready.connection)
            served += 1
        } elif ready.error == 1013 {
            return served
        }
    }
    return served
}

-- `n` serving threads; returns once the poller is closed and all have stopped.
func run_workers(poller: HttpPoller, n: Int) -> Int {
    if n <= 0 {
        return 0
    }
    val first = spawn("http-worker") {
        return serve_loop(poller)
    }
    val rest = run_workers(poller, n - 1)
    return (await first) + rest
}

A request that waits on a database or another service holds only its own thread, so run about as many threads as requests you want in flight (the CPU count plus the database pool size is a good start).

next returns error 1004 when its timeout passes and 1013 once the poller is closed. close closes every connection the poller holds and wakes next; a connection handed out by next stays its worker's, and parking it after close closes it. The poller does not own the listener. closed() tells a draining server to answer with Connection: close; parked(), waiting(), accepted() and expired() report its counts. apps/inventory is a complete service built this way. A slow connection occupies its handler until its deadline expires, so give every public endpoint bounded header and body sizes and finite timeouts. One task may receive from a WebSocket while several tasks send (complete frames are serialized); there must be only one receiver.

API summary

API Use
Http.get(url, timeout) Bounded GET.
Http.post_json(url, body, timeout) POST with a JSON content type.
Http.request(method, url, headers, body, max_body_bytes, timeout) Any method, headers and body.
Http.get_with_ca(...), Http.request_with_ca(...) Verified HTTPS with a private trust root.
Http.get_insecure(...), Http.request_insecure(...) Unverified HTTPS, local development only.
HttpClient.open(origin, ca, insecure, max_connections, idle_timeout) A bounded pool of persistent connections to one origin.
Http.listen(host, port), Http.listen_tls(host, port, chain, key) HTTP and HTTPS servers.
HttpServer.accept(server, timeout) Accept one connection.
HttpsServer.accept_tcp(server, timeout), HttpsPendingConnection.handshake(pending, timeout) TCP admission separated from the TLS handshake.
Http.poller(server, idle_timeout), poller.next(timeout), poller.park(c), poller.discard(c) Readiness-driven serving of many keep-alive connections.
HttpConnection.read_request(connection, max_header_bytes, max_body_bytes, timeout) Parse one bounded HTTP/1.1 request.
HttpConnection.send_response(connection, response, timeout) Send a complete response.
Http.json_response, Http.html_response, Http.text_response, Http.file_response, Http.response Response constructors.
Http.header(headers, name) Case-insensitive header lookup.
WebSocket.connect(url, headers, max_message_bytes, timeout) A ws:// or verified wss:// client.
HttpConnection.upgrade_websocket(connection, request, max_message_bytes, timeout) Validate an upgrade and take over the connection.
WebSocket.send_text, WebSocket.send_bytes, WebSocket.receive, WebSocket.close Frames and the closing handshake.
Net.tcp_dial, TcpSocket.read, TcpSocket.write, TcpSocket.close TCP.
Net.udp_bind, Net.udp_sendto_v4, UdpSocket.recvfrom, UdpSocket.close UDP.
Net.lookup_host, IPv4Addr.parse, IPv6Addr.parse DNS and addresses.
IO.read_bytes, IO.write_bytes, IO.append_bytes, IO.file_info, IO.copy_file, IO.move_file Bounded binary file operations.

Production checklist

Verified status

Every example in this guide is compiled by the Doc Test workflow on every change, with the pinned toolchain. The networking, TLS, WebSocket and file suites (test_stdlib_net_*, test_stdlib_http, the chunked decoding, concurrent client, HTTPS and WSS roundtrip tests, and the pure Iron fixtures and examples under examples/networking) run in CI on Linux and macOS in Debug (AddressSanitizer and UndefinedBehaviorSanitizer) and Release builds, and under ThreadSanitizer. The suites also run on Windows, where the compiler and generated programs build natively with the same pinned toolchain; HTTPS there needs OpenSSL development files, as on the other platforms (section "HTTPS").

Iron networking is alpha software: the APIs may evolve as production feedback accumulates.