net

HTTP client, streaming HTTP, TCP sockets, and UDP sockets. Supports both HTTP and HTTPS.

Oxigen
introduce net

For an HTTP server, use api, which is built on this module.

#Contents

FunctionSignatureReturns
getget(url){status, body}
postpost(url, body, headers = {"Content-Type": "application/json"}){status, body}
putput(url, body, headers = {...}){status, body}
patchpatch(url, body, headers = {...}){status, body}
deletedelete(url){status, body}
headhead(url){status, body}
requestrequest(method, url, headers, body){status, body}
downloaddownload(url, path, method?, headers?, body?)int status
post_to_filepost_to_file(url, body, path, headers?)int status
uploadupload(method, url, file_path, headers = {}){status, body}
open_streamopen_stream(url, method = "GET", headers = {}, body = "")stream handle
stream_getstream_get(url, headers = {})stream handle
stream_poststream_post(url, body, headers = {...})stream handle
stream_putstream_put(url, body, headers = {...})stream handle
stream_patchstream_patch(url, body, headers = {...})stream handle
read_lineread_line(stream, timeout_ms = 0)str or None
read_chunkread_chunk(stream, max = 4096, timeout_ms = 0)str or None
connectconnect(host, port)connection handle
listenlisten(host, port)server handle
acceptaccept(server)connection handle
sendsend(conn, data)int bytes written
receivereceive(conn, max = 4096)str
set_timeoutset_timeout(conn, ms)None
send_filesend_file(conn, path)int bytes sent
closeclose(handle)None
udp_bindudp_bind(host, port)socket handle
udp_sendudp_send(sock, data, host, port)int bytes sent
udp_receiveudp_receive(sock, max = 4096)(data, sender)

#Enum: HttpMethod

Oxigen
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:

KeyTypeContents
statusintHTTP status code
bodystrresponse body

and raises a terminal Error on any non-2xx status — see Errors below for how to inspect one instead.

#get(url)

Oxigen
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"}.

Oxigen
body := json.stringify({"name": "test"})
resp := net.post("https://api.example.com/items", body)
println(resp.status)   // 201

Posting a form instead:

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

Oxigen
println(net.delete("https://api.example.com/items/7").status)   // 204

#head(url)

Headers only — the body comes back as "".

Oxigen
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.

Oxigen
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).

Oxigen
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:

Oxigen
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.

Oxigen
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.

Oxigen
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"}.

Oxigen
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 — return None if 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.

Oxigen
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:

Oxigen
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.

Oxigen
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.

Oxigen
// 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)
Oxigen
// 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.

Oxigen
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.

Oxigen
// 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 a max of 65536; 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:

Oxigen
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: 404

That 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.

Oxigen
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-65535

This 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, 65616 reached port 80, -1 reached 65535, and net.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 % 65536 yourself 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.

CallTimeoutDefault
connectTCP handshake30 seconds, then Error: connection timed out. Not configurable.
receiveset_timeout(conn, ms)none until you set one; then an expired read returns ""
accept, udp_receive, sendnoneblock until the OS returns
read_line, read_chunktimeout_ms argument0 = 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

Oxigen
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.