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:
import netprovides TCP, UDP, IP address parsing, and DNS.import httpprovides bounded HTTP/1.1 client and server models on top of TCP. It is designed to compose withspawnandawait; the HTTP layer does not hide a scheduler or create handler threads by itself.import websocketprovides RFC 6455ws://and verifiedwss://clients, HTTP/HTTPS server upgrades, text and binary messages, fragmentation, ping/pong, and the closing handshake.import ioprovides the bounded binary-safe file operations commonly needed by network services: read, write, append, metadata, copy, and move.
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:
response.error != 0means URL parsing, TCP, timeout, limit, or HTTP framing failed;response.error_messagesays which.response.statusis meaningful only whenerror == 0. A 404 or 500 is a valid HTTP response, not a transport error.- The timeout is one budget for the whole call: connect, writes, headers and body decoding.
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.closeA 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:
Http.json_response(status, body)Http.html_response(status, body)Http.text_response(status, body)Http.file_response(status, path, content_type, max_body_bytes), which reads at most the given limit; the content type is given explicitly, Iron does not guess it from the file nameHttp.response(status, headers, body)
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
- Server requests are HTTP/1.1 with exactly one
Hostheader, or HTTP/1.0 with at most one (an HTTP/1.0 request keeps the connection open only when it asks forConnection: keep-alive). - Server request bodies accept one unambiguous
Content-Lengthor bounded chunked transfer encoding, including validated bounded trailers. - Client responses accept HTTP/1.0 and HTTP/1.1,
Content-Length, chunked transfer encoding (including trailers), or close-delimited bodies. - Duplicate framing headers, obsolete folded headers, control characters, malformed header names, and CRLF injection are rejected.
- Server header and body limits are supplied to
read_request; client body limits are supplied torequest. Convenience client calls use an 8 MiB body limit and a 64 KiB header limit. - One-shot calls send
Connection: close; explicit sessions use framed sequential HTTP persistence without pipelining.
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
- Bind deliberately: loopback for local tools, a public interface only when intended.
- Set finite accept, header, body, handler and upstream timeouts.
- For public HTTPS and WSS listeners, use
accept_tcpand run each bounded TLS handshake in its handler task. - Set endpoint-specific body limits instead of the convenience maximum.
- Check both the transport error and the application status code.
- Close every accepted connection on every handler path.
- Use HTTPS and WSS, keep certificate verification on, protect private keys.
- Authentication, authorization, rate limiting, logging and graceful shutdown belong to the application.
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.