Skip to content

About

A network-client library for Odin

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

netclients

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-stream reader that delivers events as they arrive (not a buffered response), with auto-reconnect (Last-Event-ID + the server's retry:). 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 — see BITCOIN.md for the planned bitcoin package 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. See CLAUDE.md for the authoritative status.


Which package do I need?

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 ws and sse codec packages already own those names — hence client/wsclient (package wsclient) and client/sseclient (package sseclient). 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.


Requirements

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.


Installing the requirements

Odin

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 newer

OpenSSL 3.x — per OS

macOS (Homebrew):

brew install openssl@3

openssl@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 openssl

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

  1. Make the import libraries (libssl.lib, libcrypto.lib) available to the linker (add their directory to LIB, or copy them into your project).
  2. Ship the runtime DLLs (libssl-3-x64.dll, libcrypto-3-x64.dll) on the PATH next to your executable.

If OpenSSL is missing, the build fails at link time with a clear unresolved-symbol error for the libssl/libcrypto imports.


Quick start

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

A reusable client (pooling, redirects, cookies, TLS options)

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)

JSON-RPC over HTTP (Bitcoin Core flavor)

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.

WebSocket

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

Server-Sent Events (SSE)

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)

ZeroMQ SUB (Bitcoin Core ZMQ)

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 (core:nbio) and threaded

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


Examples

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 notifications

Building & testing

odin 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=1

There is no root package any more, so the old odin test . -all-packages has nothing to point at — ./test.sh is the replacement. No single package imports everything (http2 is 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.


Design

Two ideas carry the whole library (see CLAUDE.md for the full architecture):

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

  2. 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, never SSL_set_fd).

  3. One package per transport, over a shared core. The dependency graph is a DAG with core at 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.

License

See repository.

About

A network-client library for Odin

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages