A network-client library for Odin, built on one shared sans-I/O engine:
- HTTP/1.1 + HTTPS client — methods, chunked & content-length & close-delimited bodies, keep-alive connection pooling, redirects, transparent gzip/deflate, Basic/Bearer auth, a cookie jar, and TLS (SNI/ALPN, verify-on-by-default, custom CA).
- WebSocket client — the full RFC 6455 client protocol: masking, all frame length forms, fragmentation with interleaved control frames, auto-pong, the close handshake with the full close-code set, and incremental UTF-8 validation.
- JSON-RPC 2.0 client — over HTTP (the Bitcoin Core / Drivechain RPC path) or WebSocket (long-lived, multiplexed, with server-push notifications). Typed and dynamic calls, batches, and a strict three-tier error model.
- Server-Sent Events (SSE) client — a streaming
text/event-streamreader that delivers events as they arrive (not a buffered response), with auto-reconnect (Last-Event-ID + the server'sretry:). Sync, async (core:nbio), and threaded. - ZeroMQ SUB client — a ZMTP 3.1 subscriber over
tcp://(NULL security), for Bitcoin Core ZMQ notifications. Multipart messages[topic, body, sequence], PING→PONG keepalive, subscribe/unsubscribe. (A focused subset, not all of ZeroMQ — seeBITCOIN.mdfor the plannedbitcoinpackage that pairs it with Core RPC.)
Every client runs synchronously, asynchronously (core:nbio), or on a
thread pool (core:thread) over the same protocol code.
Status: pre-1.0. Implemented and tested: sync + async (
core:nbio) + threaded HTTP/S with pooling, the full RFC 6455 WebSocket client (sync and async-over-TLS), JSON-RPC 2.0 over HTTP and WebSocket, and the HTTP feature set (redirects, cookies, compression, auth, body encoders). HTTP/2: the HPACK (RFC 7541) codec is implemented and vector-tested; framing/multiplexing is the remaining v0.6 work. Async TLS is implemented across HTTP/RPC/WebSocket/SSE. permessage-deflate and a few HTTP edge features are still future work. SeeCLAUDE.mdfor the authoritative status.
| Requirement | Notes |
|---|---|
| Odin compiler | dev-2026-06 or newer (uses the redesigned core:os, [dynamic; N]T, #+feature/#+build). |
| OpenSSL 3.x | Required for TLS (https://, wss://) and the WebSocket accept hash (SHA-1). Linked at build time; must be installed on the system (by design). |
| OS | macOS, Linux, and Windows are all supported. |
The sans-I/O protocol cores have no third-party dependencies — only core: and
base: packages. OpenSSL is the single external native dependency.
Install the Odin compiler (dev-2026-06+) and put odin on your PATH. See
https://odin-lang.org/docs/install/. Verify with:
odin version # expect dev-2026-06 or newermacOS (Homebrew):
brew install openssl@3openssl@3 is keg-only, so this library adds the Homebrew lib directory to the
linker search path automatically:
- Apple Silicon:
/opt/homebrew/opt/openssl@3/lib - Intel:
/usr/local/opt/openssl@3/lib
No further configuration is needed.
Linux (system OpenSSL + headers/dev package):
# Debian / Ubuntu
sudo apt-get install libssl-dev
# Fedora / RHEL
sudo dnf install openssl-devel
# Arch
sudo pacman -S opensslThe library links system:ssl and system:crypto.
Windows (OpenSSL 3.x):
Install an OpenSSL 3.x distribution (e.g. the
Shining Light "Win64 OpenSSL"
installer, or vcpkg install openssl). Then:
- Make the import libraries (
libssl.lib,libcrypto.lib) available to the linker (add their directory toLIB, or copy them into your project). - Ship the runtime DLLs (
libssl-3-x64.dll,libcrypto-3-x64.dll) on thePATHnext to your executable.
If OpenSSL is missing, the build fails at link time with a clear unresolved-symbol
error for the libssl/libcrypto imports.
package main
import nc "netclients" // adjust to your import path
import "core:fmt"
main :: proc() {
// --- HTTP GET (sync) ---
resp, err := nc.http_get("https://example.com/")
if err != nil {
fmt.eprintfln("error: %v", err)
return
}
defer nc.response_destroy(&resp)
fmt.printfln("%d %s", resp.status.code, nc.response_body_string(&resp))
}jar: nc.Cookie_Jar
nc.jar_init(&jar)
defer nc.jar_destroy(&jar)
opts := nc.default_client_options()
opts.cookie_jar = &jar // store/replay cookies
// opts.insecure_skip_verify = true // explicit, named, loud — off by default
c := nc.client_make(opts)
defer nc.client_destroy(&c) // closes pooled idle connections
req: nc.Request
nc.request_init(&req, .Post, "https://api.example.com/things", context.allocator)
defer nc.request_destroy(&req)
nc.headers_set(&req.headers, "Authorization", nc.bearer_auth_header("token"))
req.body = nc.form_urlencode({{"name", "ada"}, {"role", "admin"}})
resp := nc.client_do(&c, &req) or_else nc.Response{}
defer nc.response_destroy(&resp)rpc, _ := nc.rpc_http_make("http://user:pass@127.0.0.1:8332/") // Basic auth from URL
defer nc.rpc_destroy(&rpc)
count := nc.rpc_call_typed(&rpc, "getblockcount", nil, int) or_return
hash := nc.rpc_call_typed(&rpc, "getblockhash", []int{count}, string) or_return
defer delete(hash)A server-returned error surfaces as an Rpc_Fault (distinct from transport and
protocol errors) — match it for code/message/data.
ws := nc.ws_dial("wss://echo.example.com/") or_return
defer nc.ws_destroy(&ws)
nc.ws_send_text(&ws, "hello") or_return
msg := nc.ws_recv(&ws) or_return
defer nc.ws_message_destroy(&msg)
nc.ws_close(&ws, 1000, "bye")conn := nc.sse_connect("https://stream.example.com/events") or_return // auto-reconnects
defer nc.sse_destroy(&conn)
for {
ev, err := nc.sse_recv(&conn) // blocks until the next event
if err != nil { break } // stream ended (with auto_reconnect off)
defer nc.sse_event_destroy(&ev)
fmt.printfln("[%s] %s", ev.event, ev.data) // ev.event / ev.data / ev.id
}
// Async (core:nbio): callbacks on the event loop — see examples/sse_listen_async.
conn := nc.async_sse_connect(url, on_open, on_event, on_error, &state) or_return
defer nc.async_sse_destroy(conn)
nbio.run()
// Threaded: stream on a background worker, callbacks fire there — see examples/sse_listen_threaded.
stream := nc.sse_stream_start(url, on_event, on_error, &state)
// ... main thread stays free ...
nc.sse_stream_stop(stream)A ZMTP 3.1 subscriber over tcp:// (NULL security) — Bitcoin Core is the PUB, this is the
SUB. Each notification is multipart: [topic, body, sequence(LE u32)].
// nil topics → subscribe to everything; or pass prefix filters.
sub := nc.zmq_sub_dial("tcp://127.0.0.1:28332", []string{"hashblock", "rawtx"}) or_return
defer nc.zmq_sub_destroy(&sub)
for {
msg, err := nc.zmq_recv(&sub) // blocks until the next multipart message
if err != nil { break } // Net_Error.Connection_Closed at stream end
defer nc.zmq_message_destroy(&msg)
fmt.printfln("[%s] %d bytes", nc.zmq_message_topic(msg), len(nc.zmq_message_body(msg)))
}ZMQ is best-effort — treat it as a low-latency hint and confirm authoritative state via RPC.
See examples/zmq_bitcoin and BITCOIN.md.
// Async: drive your own nbio event loop.
nbio.acquire_thread_event_loop()
defer nbio.release_thread_event_loop()
nc.async_get("http://example.com/", on_response, &state)
nbio.run_until(&state.done)
// Threaded: a worker pool returning Futures.
tp: nc.Thread_Pool
nc.pool_start(&tp, &client, 8)
defer nc.pool_shutdown(&tp)
f := nc.pool_submit(&tp, .Get, "http://example.com/")
resp := nc.future_await(f)Runnable programs live under examples/:
odin run examples/sync_get -- https://example.com/
odin run examples/async_get -- http://example.com/ # core:nbio HTTP GET
odin run examples/ws_echo -- wss://echo.websocket.org/
odin run examples/rpc_bitcoin -- http://user:pass@127.0.0.1:8332/
odin run examples/ws_nostr # stream Nostr kind-1 (sync WS)
odin run examples/ws_nostr_async # same, over core:nbio (async TLS + WS)
odin run examples/sse_listen # stream Wikimedia's live SSE feed
odin run examples/sse_listen_async # same, over core:nbio (async TLS)
odin run examples/sse_listen_threaded # same, on a background thread
odin run examples/zmq_bitcoin -- tcp://127.0.0.1:28332 # Bitcoin Core ZMQ notificationsodin build examples/sync_get # build an example (a buildable program)
odin check . # type-check the library (it has no main)
# Full local suite: the sans-I/O codecs (http1, ws, jsonrpc, http2/hpack) plus the
# integration tests (loopback HTTP/TLS/WebSocket servers, async + threaded drivers).
# Run single-threaded for fully deterministic, leak-clean results (also what CI uses):
odin test . -all-packages -define:ODIN_TEST_THREADS=1 -define:ODIN_TEST_TRACK_MEMORY=true
odin test ./http2 -define:ODIN_TEST_THREADS=1 # http2 isn't imported by root
# Optional real-network HTTPS smoke test (off by default):
NETCLIENTS_NET_TESTS=1 odin test . -all-packages -define:ODIN_TEST_THREADS=1CI vs. local. CI (
.github/workflows/ci.yml) runs the deterministic, dependency-free codec tests (http1/ws/jsonrpc/http2) plus type-check and example builds — no servers, no network. The integration suite (loopback servers, drivers) is best run locally with the command above.
Two ideas carry the whole library (see CLAUDE.md for the full architecture):
-
Sans-I/O protocol cores. TLS, HTTP/1.1, the WebSocket frame codec, the HPACK codec, and the JSON-RPC codec perform no I/O — they consume input bytes, emit events, and produce output bytes. This lets each protocol be written once and run synchronously, asynchronously, or threaded, and makes them testable against byte slices with no network (RFC spec vectors, byte-split fuzzing, adversarial inputs).
-
Drivers move bytes. Thin per-execution-model drivers (
blocking,async,threaded) shuttle bytes between a socket and the codecs and compose the TLS layer (driven through OpenSSL memory BIOs, neverSSL_set_fd).
TLS verification is on by default; disabling it is explicit and named
(insecure_skip_verify). Security-relevant randomness (WebSocket masking keys,
Sec-WebSocket-Key) comes from an injectable RNG so it can be tested against fixed
RFC vectors.
See repository.