net
HTTP client, streaming HTTP, TCP sockets, and UDP sockets. Supports both HTTP and HTTPS.
introduce netFor an HTTP server, use api, which is built on this module.
#Contents
- Enum:
HttpMethod - HTTP requests —
get,post,put,patch,delete,head,request - Streaming HTTP —
download,post_to_file,upload,open_stream,stream_*,read_line,read_chunk - TCP sockets —
connect,listen,accept,send,receive,set_timeout,close - UDP sockets —
udp_bind,udp_send,udp_receive - Errors, ports, blocking and timeouts, receive buffers
| Function | Signature | Returns |
|---|---|---|
get | get(url) | {status, body} |
post | post(url, body, headers = {"Content-Type": "application/json"}) | {status, body} |
put | put(url, body, headers = {...}) | {status, body} |
patch | patch(url, body, headers = {...}) | {status, body} |
delete | delete(url) | {status, body} |
head | head(url) | {status, body} |
request | request(method, url, headers, body) | {status, body} |
download | download(url, path, method?, headers?, body?) | int status |
post_to_file | post_to_file(url, body, path, headers?) | int status |
upload | upload(method, url, file_path, headers = {}) | {status, body} |
open_stream | open_stream(url, method = "GET", headers = {}, body = "") | stream handle |
stream_get | stream_get(url, headers = {}) | stream handle |
stream_post | stream_post(url, body, headers = {...}) | stream handle |
stream_put | stream_put(url, body, headers = {...}) | stream handle |
stream_patch | stream_patch(url, body, headers = {...}) | stream handle |
read_line | read_line(stream, timeout_ms = 0) | str or None |
read_chunk | read_chunk(stream, max = 4096, timeout_ms = 0) | str or None |
connect | connect(host, port) | connection handle |
listen | listen(host, port) | server handle |
accept | accept(server) | connection handle |
send | send(conn, data) | int bytes written |
receive | receive(conn, max = 4096) | str |
set_timeout | set_timeout(conn, ms) | None |
send_file | send_file(conn, path) | int bytes sent |
close | close(handle) | None |
udp_bind | udp_bind(host, port) | socket handle |
udp_send | udp_send(sock, data, host, port) | int bytes sent |
udp_receive | udp_receive(sock, max = 4096) | (data, sender) |
#Enum: HttpMethod
net.HttpMethod.get // .value == "GET"
net.HttpMethod.post // "POST"
net.HttpMethod.put // "PUT"
net.HttpMethod.patch // "PATCH"
net.HttpMethod.delete // "DELETE"Only request takes one of these; the
shorthand functions have the method baked in.
#HTTP requests
Every function in this section returns a map:
| Key | Type | Contents |
|---|---|---|
status | int | HTTP status code |
body | str | response body |
and raises a terminal Error on any non-2xx status — see
Errors below for how to inspect one instead.
#get(url)
introduce net
introduce json
resp := net.get("https://api.example.com/data")
println(resp.status) // 200
data := json.parse(resp.body)#post(url, body, headers)
headers defaults to {"Content-Type": "application/json"}.
body := json.stringify({"name": "test"})
resp := net.post("https://api.example.com/items", body)
println(resp.status) // 201Posting a form instead:
net.post(url, "a=1&b=2", {"Content-Type": "application/x-www-form-urlencoded"})#put(url, body, headers)
Same shape as post.
#patch(url, body, headers)
Same shape as post.
#delete(url)
println(net.delete("https://api.example.com/items/7").status) // 204#head(url)
Headers only — the body comes back as "".
println(net.head("https://example.com/").status) // 200#request(method, url, headers, body)
Full control. method is an HttpMethod enum member, not
a string. Pass None for a body when there is none.
resp := net.request(net.HttpMethod.get,
"https://api.example.com/secure",
{"Authorization": "Bearer token123"},
None)#Streaming HTTP
download/upload move a payload without ever holding it all in memory.
open_stream hands you the response body incrementally — LLM token streams,
SSE, chunked responses, long polls.
A stream applies back-pressure: the reader stays at most ~72 KiB ahead of you, so a consumer slower than the network cannot accumulate the whole body in memory.
#download(url, path, method?, headers?, body?)
Stream a response body straight to a file. Returns the HTTP status code. The bytes are written as they arrive, so a binary response (audio, an image, an archive) is saved intact, which a string body cannot do.
It sends a GET by default. Pass method "POST", "PUT" or "PATCH", with
headers and a body, to download what a request returns, such as audio from
a text-to-speech API:
Because it writes a file, an embedded VM needs the filesystem capability as
well as network to call it (see embedding).
status := net.download("https://example.com/big.iso", "big.iso")
println(status) // 200#post_to_file(url, body, path, headers?)
download(url, path, "POST", headers, body), with JSON as the default
content type:
introduce net
introduce json
body := json.stringify({"model": "tts-1", "voice": "alloy", "input": "Hello!"})
status := net.post_to_file("https://api.example.com/v1/audio/speech", body, "hello.mp3", {
"Content-Type": "application/json",
"Authorization": "Bearer " + key,
})#upload(method, url, file_path, headers)
Stream a file as the request body. Returns {status, body}.
It reads a local file, so like download it needs both the network and the
filesystem capability in a restricted VM.
resp := net.upload("POST", "https://example.com/upload", "big.iso",
{"Content-Type": "application/octet-stream"})#open_stream(url, method, headers, body)
Open a streaming request and return a stream handle. Read it with
read_line or
read_chunk; free it with
close.
net.open_stream(url) // GET
net.open_stream(url, "POST", headers, body) // e.g. an LLM API#stream_get, stream_post, stream_put, stream_patch
Method shorthands over open_stream. The three with a body default to
{"Content-Type": "application/json"}.
s := net.stream_get("http://localhost:8000/events")
s := net.stream_post(url, json.stringify({"prompt": "hi"}))#read_line(stream, timeout_ms)
Read the next newline-delimited line, without the newline. This is the easy way to consume NDJSON and SSE: one call, one complete record.
timeout_ms = 0(default) — block until a full line or end of stream.timeout_ms > 0— returnNoneif no full line has arrived yet.- Returns
""at end of stream.
It returns whole UTF-8 characters, so emoji, accents and CJK are never split across two reads.
A line longer than 8 MiB is an error rather than an ever-growing buffer: a
server that never sends a newline cannot exhaust memory. Use
read_chunk for such streams.
introduce net
introduce json
headers := {"Content-Type": "application/json"}
body := json.stringify({"model": "llama3", "prompt": "Say hi"})
s := net.open_stream("http://localhost:11434/api/generate", "POST", headers, body)
done := False
repeat unless done {
line := net.read_line(s) // one NDJSON object, or "" at EOF
option {
line == "" -> { done = True }
{
obj := json.parse(line)
print(obj["response"]) // token, live
done = obj["done"]
}
}
}
net.close(s)The timeout_ms form is how you animate a spinner while the model thinks:
line := net.read_line(s, 100)
option {
line == None -> { print(spinner_frame()) } // nothing yet
{ handle(line) }
}#read_chunk(stream, max, timeout_ms)
Lower-level: read up to max raw bytes, for binary or non-line protocols.
Same timeout rules; "" at end of stream. Prefer read_line for text.
#TCP sockets
A handle is an opaque ticket number for an open socket. The numbers are
not sequential: every VM in a process shares one table of sockets, so handles
are drawn at random to keep one script from guessing another's. Pass it to
send/receive/close. Always close a handle when you are done — a
forgotten handle leaks one connection until the program exits, like a file you
never close.
#connect(host, port)
Open a TCP connection; returns a connection handle.
#listen(host, port)
Bind a TCP server socket; returns a server handle.
#accept(server)
Block until a client connects; returns a connection handle for that client.
#send(conn, data)
Send text; returns the number of bytes written.
#send_file(conn, path)
Send a file's bytes on a connection unchanged and return how many were written.
send takes text, so this is how to send an image or any
other binary data without corrupting it. It reads a local file, so a restricted
VM needs the filesystem capability as well as network.
net.send(conn, "HTTP/1.1 200 OK\r\nContent-Length: {io.file_size("logo.png")}\r\n\r\n")
net.send_file(conn, "logo.png")#receive(conn, max)
Read up to max bytes. Returns "" on a clean close — and also when a
set_timeout deadline passes with nothing to read.
#set_timeout(conn, ms)
Bound how long receive waits on this connection. 0 restores waiting
forever.
#close(handle)
Close a connection or server handle. Idempotent.
// Server
introduce net
srv := net.listen("127.0.0.1", 8080)
conn := net.accept(srv)
net.set_timeout(conn, 2000)
msg := net.receive(conn)
net.send(conn, "echo: " + msg)
net.close(conn)
net.close(srv)// Client
introduce net
c := net.connect("127.0.0.1", 8080)
println(net.send(c, "hello")) // 5
println(net.receive(c)) // echo: hello
net.close(c)Socket I/O works on UTF-8 text — the common case (HTTP, line protocols, JSON over TCP).
#UDP sockets
#udp_bind(host, port)
Bind a UDP socket; returns a socket handle.
#udp_send(sock, data, host, port)
Send one datagram to host:port; returns the number of bytes sent.
#udp_receive(sock, max)
Receive one datagram (up to max bytes). Returns a (data, sender_address)
tuple.
introduce net
sock := net.udp_bind("127.0.0.1", 9000)
net.udp_send(sock, "ping", "127.0.0.1", 9000)
data, sender := net.udp_receive(sock)
println(data) // ping
net.close(sock)A datagram larger than max is an error, not a short read. UDP has no
stream to resume from: the OS keeps what fits your buffer and discards the
rest.
// a 16-byte datagram arrives
net.udp_receive(sock, 4)
// error: udp_receive: datagram is larger than max (4 bytes); the rest was
// discarded by the OS and cannot be recovered — retry with a larger max
// (up to 65536)This changed. That call used to return
"0123"and drop 12 bytes with no indication anything was missing — a partial message that looks exactly like a complete one. If you want whatever arrives regardless of size, pass amaxof65536; no datagram can exceed it, so the error can never fire.
#Errors
On failure — connection refused, host not found, file missing, non-2xx HTTP
status — every net function returns a terminal Error that halts
execution. Normalize the call into a value you can inspect:
introduce net
introduce result
res := <type<Error> || <Value>>(net.connect("127.0.0.1", 9999))
println(res.msg) when result.is_err(res)
// Connection refused (os error 61)
resp := <type<Error> || <Value>>(net.get("http://localhost:8000/missing"))
println(resp.msg) when is_error(resp)
// http error: http status: 404That last one is worth remembering: because a non-2xx raises, a client cannot read the error body a server sent unless the call is normalized first.
#Ports
connect, listen, udp_bind and udp_send validate the port before use.
The accepted range is 1–65535, plus 0 for the two binding calls
(listen and udp_bind), where 0 means "let the OS pick a free ephemeral
port". Connecting or sending to port 0 is never meaningful and is rejected.
net.connect("127.0.0.1", 74626) // Error: port 74626 out of range: must be 1-65535
net.connect("127.0.0.1", 0) // Error: port 0 out of range: must be 1-65535
net.listen("127.0.0.1", 65536) // Error: port 65536 out of range: must be 0-65535
net.udp_bind("127.0.0.1", -1) // Error: port -1 out of range: must be 0-65535This changed. An out-of-range port used to be truncated to its low 16 bits and silently used.
net.connect(host, 74626)connected to port 9090,65616reached port 80,-1reached 65535, andnet.listen(host, 65536)masked to 0 and bound a random ephemeral port while reporting success — a server that believed it was listening on the port you asked for. If you have code that computed a port arithmetically and relied on the wrap-around, it now errors instead; apply the% 65536yourself if that was deliberate.
#Blocking and timeouts
All socket calls are synchronous and block the calling VM until the OS
operation completes: connect waits for the handshake, accept waits for a
client, receive/udp_receive wait for data. Run them under diverge (see
concurrency.md) so a slow peer stalls one thread instead
of your program.
| Call | Timeout | Default |
|---|---|---|
connect | TCP handshake | 30 seconds, then Error: connection timed out. Not configurable. |
receive | set_timeout(conn, ms) | none until you set one; then an expired read returns "" |
accept, udp_receive, send | none | block until the OS returns |
read_line, read_chunk | timeout_ms argument | 0 = block forever; > 0 returns None when nothing has arrived yet |
Set a receive timeout on any connection a remote peer can hold open. A
server that accepts a connection and reads from it has handed a stranger the
power to park that thread indefinitely by connecting and saying nothing; with a
timeout the read ends and the thread moves on. An expired read reports "",
the same as a closed peer — to a caller that asked to stop waiting, a silent
client and a departed one are the same thing, and reporting it as an error
would make every timeout something to unwrap.
The connect bound exists because a blackholed SYN otherwise hangs forever, and
program exit waits for spawned tasks to finish — one stuck connect wedged the
whole process. Name resolution happens before the timer starts and is still
bounded only by the OS resolver.
#Receive buffers
receive/udp_receive take max as an upper bound, not an allocation
request: a single TCP receive returns at most 64 KiB however large max
is (it is one read of a byte stream, so it already returned "up to max" —
loop if you want more), and a UDP datagram cannot exceed 64 KiB in the first
place, so the internal cap never truncates. max must be positive.
Previously the buffer was allocated at exactly max, so
receive(c, 10000000000) tried to allocate 10 GB up front — and an allocation
failure aborts the process outright, with no catchable error.
#Worked example
introduce net
introduce json
introduce result
// A tiny API client that reports failures instead of halting.
fun api_get(url <str>) {
r := <type<Error> || <Value>>(net.get(url))
option {
is_error(r) -> { <Error<http>>(r.msg) }
{ json.parse(r.value.body) }
}
}
data := result.unwrap_or(api_get("https://api.example.com/items"), [])
println(len(data))See also: api for the HTTP server, json for bodies, encoding for auth headers and query escaping, concurrency.md for running blocking calls off the main thread.