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.
netclients is a set of small packages, not one big one — import only the
transport you use. core is the shared base (the Error union, Url, headers,
the RNG, the cookie jar, options, and the socket+TLS Transport); every
transport package depends on it, and nothing depends on a transport.
| I want to… | import | also import core for |
|---|---|---|
| make HTTP/HTTPS requests | netclients/client/http |
Error, Cookie_Jar, default_client_options |
| open a WebSocket | netclients/client/wsclient |
Error, Net_Error |
| call JSON-RPC over HTTP (Bitcoin Core) | netclients/client/rpc |
Error, Rpc_Fault, Net_Error |
| call JSON-RPC over WebSocket | netclients/client/rpcws |
Error, Rpc_Fault |
read an SSE / text/event-stream |
netclients/client/sseclient |
Error |
| subscribe to ZeroMQ (Bitcoin Core ZMQ) | netclients/client/zmq |
Error, Net_Error |
Only client/rpc depends on another transport (client/http), and only
client/rpcws depends on client/wsclient. Everything else depends on core
alone, so a ZeroMQ consumer never compiles the HTTP client and a WebSocket
consumer never compiles JSON-RPC.
core ──┬── client/http ──── client/rpc (JSON-RPC over HTTP)
├── client/wsclient ─ client/rpcws (JSON-RPC over WebSocket)
├── client/sseclient
└── client/zmq
The sans-I/O codecs sit beside them and are usually not imported directly:
http1, http2 (HPACK), ws, jsonrpc, sse, zmtp, tls.
Package names vs. directory names. Odin requires package names to be globally unique, and the
wsandssecodec packages already own those names — henceclient/wsclient(packagewsclient) andclient/sseclient(packagesseclient). Alias them at your import site if you prefer:import ws "netclients/client/wsclient".
Upgrading from the single-package layout (import nc "netclients")? See
MIGRATION.md.
| 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 "core:fmt"
import http "netclients/client/http" // adjust to your import path
main :: proc() {
// --- HTTP GET (sync) ---
resp, err := http.http_get("https://example.com/")
if err != nil {
fmt.eprintfln("error: %v", err)
return
}
defer http.response_destroy(&resp)
fmt.printfln("%d %s", resp.status.code, http.response_body_string(&resp))
}import core "netclients/core"
import http "netclients/client/http"
jar: core.Cookie_Jar // the jar and the options live in core
core.jar_init(&jar)
defer core.jar_destroy(&jar)
opts := core.default_client_options()
opts.cookie_jar = &jar // store/replay cookies
// opts.insecure_skip_verify = true // explicit, named, loud — off by default
c := http.client_make(opts)
defer http.client_destroy(&c) // closes pooled idle connections
req: http.Request
http.request_init(&req, .Post, "https://api.example.com/things", context.allocator)
defer http.request_destroy(&req)
core.headers_set(&req.headers, "Authorization", http.bearer_auth_header("token"))
req.body = http.form_urlencode({{"name", "ada"}, {"role", "admin"}})
resp := http.client_do(&c, &req) or_else http.Response{}
defer http.response_destroy(&resp)import rpc "netclients/client/rpc"
node, _ := rpc.rpc_http_make("http://user:pass@127.0.0.1:8332/") // Basic auth from URL
defer rpc.rpc_destroy(&node)
count := rpc.rpc_call_typed(&node, "getblockcount", nil, int) or_return
hash := rpc.rpc_call_typed(&node, "getblockhash", []int{count}, string) or_return
defer delete(hash)Note the client is named node, not rpc: a local named rpc would shadow the
package alias. A server-returned error surfaces as a core.Rpc_Fault (distinct
from transport and protocol errors) — match it for code/message/data.
import ws "netclients/client/wsclient" // alias it to whatever reads best
conn := ws.ws_dial("wss://echo.example.com/") or_return
defer ws.ws_destroy(&conn)
ws.ws_send_text(&conn, "hello") or_return
msg := ws.ws_recv(&conn) or_return
defer ws.ws_message_destroy(&msg)
ws.ws_close(&conn, 1000, "bye")import sse "netclients/client/sseclient"
conn := sse.sse_connect("https://stream.example.com/events") or_return // auto-reconnects
defer sse.sse_destroy(&conn)
for {
ev, err := sse.sse_recv(&conn) // blocks until the next event
if err != nil { break } // stream ended (with auto_reconnect off)
defer sse.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 := sse.async_sse_connect(url, on_open, on_event, on_error, &state) or_return
defer sse.async_sse_destroy(conn)
nbio.run()
// Threaded: stream on a background worker, callbacks fire there — see examples/sse_listen_threaded.
stream := sse.sse_stream_start(url, on_event, on_error, &state)
// ... main thread stays free ...
sse.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)].
import zmq "netclients/client/zmq" // imports only core + zmtp — no HTTP client
// nil topics → subscribe to everything; or pass prefix filters.
sub := zmq.zmq_sub_dial("tcp://127.0.0.1:28332", []string{"hashblock", "rawtx"}) or_return
defer zmq.zmq_sub_destroy(&sub)
for {
msg, err := zmq.zmq_recv(&sub) // blocks until the next multipart message
if err != nil { break } // core.Net_Error.Connection_Closed at stream end
defer zmq.zmq_message_destroy(&msg)
fmt.printfln("[%s] %d bytes", zmq.zmq_message_topic(msg), len(zmq.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()
http.async_get("http://example.com/", on_response, &state)
nbio.run_until(&state.done)
// Threaded: a worker pool returning Futures.
tp: http.Thread_Pool
http.pool_start(&tp, &client, 8)
defer http.pool_shutdown(&tp)
f := http.pool_submit(&tp, .Get, "http://example.com/")
resp := http.future_await(f)Each transport has its own async driver in its own package —
wsclient.async_ws_dial, sseclient.async_sse_connect, zmq.async_zmq_sub_dial,
rpc.async_rpc_call — all over core:nbio, all completing the TLS handshake
through the OpenSSL memory BIOs inside the nbio callbacks.
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)
# Type-check one library package. `odin check <pkg>` alone fails with
# "Undefined entry point procedure 'main'" — these packages have no main:
odin check ./core -no-entry-point
odin check ./client/http -no-entry-point
odin check ./client/wsclient -no-entry-point # ... and so on per package
# Full local suite: every codec plus the integration tests (loopback HTTP/TLS/
# WebSocket/ZMQ servers, async + threaded drivers). Single-threaded for fully
# deterministic, leak-clean results (also what CI uses).
./test.sh # 186 tests across 14 packages
# Or drive one package directly. `-all-packages` sweeps that package's
# dependencies, so this covers core + client/http + http1 + jsonrpc + tls too:
odin test ./client/rpc -all-packages -define:ODIN_TEST_THREADS=1 -define:ODIN_TEST_TRACK_MEMORY=true
# Optional real-network HTTPS smoke test (off by default):
NETCLIENTS_NET_TESTS=1 odin test ./client/http -define:ODIN_TEST_THREADS=1There is no root package any more, so the old
odin test . -all-packageshas nothing to point at —./test.shis the replacement. No single package imports everything (http2is imported by nothing), so the suite is the union of the leaf packages.
CI 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). -
One package per transport, over a shared
core. The dependency graph is a DAG withcoreat the bottom, so importing the WebSocket client does not compile the HTTP client, and importing the ZeroMQ client compiles neither.
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.