Guide

From a socket to a pool.

Each step runs on its own, and every output below is what the code printed when it ran.

Install

Add the module, then import the packages you use. The front door brings the connection, the datagram socket and the contract; an adapter is named on its own.

shell
moon add moonbitstack/moonnet
moon.pkg
import {
  "moonbitstack/moonnet",
  "moonbitstack/moonnet/adapt/h2",
  "moonbitstack/moonnet/spec",
}

A first connection

A listener and a client in one program. The listener takes every connection on its own task, reads a line, writes it back, and stops when the peer closes. The client does the same from the other side.

echo.mbt
async fn main {
  @async.with_task_group(g => {
    let serving = g.spawn(() => {
      @tcp.serve((conn, _peer) => {
        while conn.read_until("\n") is Some(line) {
          conn.write(line + "\n")
        }
      }, port=12000)
    })
    let conn = @tcp.dial("127.0.0.1", port=12000)
    conn.write("hello\n")
    println(conn.read_until("\n").unwrap_or("-"))
    conn.shutdown()
    serving.cancel()
  })
}
hello

serve listens on 127.0.0.1:12000 unless it is told otherwise, and one Conn may be read by one task while another writes: writes are serialised, so two tasks putting whole messages on the wire do not interleave their octets.

TLS and ALPN

Give dial a client config and the connection is TLS 1.3 after the handshake; leave it out and the connection is in the clear. The config carries the ALPN list the server chooses from.

tls.mbt
let tls = @tls.Client::new(@roots.mozilla(), alpn=["h2", "http/1.1"])
let conn = @tcp.dial("example.com", port=443, tls=tls)
println(conn.alpn().unwrap_or("none"))   // h2
h2

The front door does the same in one call: the scheme decides whether TLS is used, the ALPN list is offered as given, and the chain is judged against Mozilla's roots unless you pass your own.

connect.mbt
let opened = @moonnet.connect("https://example.com/status", alpn=["h2", "http/1.1"])
println(opened.alpn)     // h2
println(opened.target)   // /status
h2 /status

The contract

An exchange is a head and a body, in both directions, and everything above this library is written against that and nothing else.

MethodWhat it does
open(request)send a head and whatever body came with it; the exchange is a Stream
send(stream, bytes, end~)more of a streamed request body; end marks the last of it
response(stream)the reply's head, once the peer has sent it
read(stream)the next run of the reply's body; None ends it
trailers(stream)the fields that came after the body
is_ready()whether another exchange may start now
kind()the protocol ALPN settled on, for logs
close()end the connection the way the protocol asks

Two helpers cover the usual shape. call sends a request and reads the whole reply; read_to_end reads a body with a limit, so a peer cannot make the reader hold an unbounded amount.

call.mbt
let (response, body) = @spec.call(conn, @spec.Request::new("GET", "/status"))
println("\{response.status} \{body.length()}")

// A body written after the exchange is open, in pieces of any length.
let stream = conn.open(
  @spec.Request::new("POST", "/upload", body=@spec.Body::Streaming),
)
conn.send(stream, b"first,", end=false)
conn.send(stream, b"last", end=true)
let head = conn.response(stream)

HTTP/1.1

One exchange at a time, which is what the protocol carries. is_ready is false while one is in flight. A body is framed by a declared length, by chunks with their trailers, or by the close of the connection; a reply that stops short of its declared length raises instead of returning half a body.

h1.mbt
let conn = @h1.connect("example.com", port=443, tls=tls)
let (response, body) = @spec.call(
  conn,
  @spec.Request::new("POST", "/echo", body=@spec.Body::Bytes(b"abcdefghijkl")),
)
println(conn.kind())      // http/1.1
println(response.status)  // 200
println(body)             // b"abcdefghijkl"
conn.close()
http/1.1 200 b"abcdefghijkl"

HTTP/2

The preface and this side's settings go out when the connection opens. Every exchange takes its own stream, so a second request may start while the first is still being read — the replies arrive in whatever order the server sends them, and each is asked for by its own stream.

h2.mbt
let conn = @h2.connect("example.com", port=443, tls=tls)
let first = conn.open(@spec.Request::new("GET", "/one"))
let second = conn.open(@spec.Request::new("GET", "/two"))
println(conn.is_ready())              // true
let one = conn.response(first)
let two = conn.response(second)
println("\{one.status} \{two.status}")  // 200 200
println(@spec.read_to_end(conn, first).length())

Flow control is honoured in both directions: a request body goes out as the window allows and waits to be widened, and the octets read are handed back to the peer's window, so a reply larger than one window still arrives.

HTTP/3

HTTP/3 runs on a QUIC connection over a datagram socket, so it needs TLS: the ALPN is h3 and the handshake happens inside QUIC rather than on a TCP stream.

h3.mbt
let tls = @tls.Client::new(@roots.mozilla(), alpn=["h3"])
let conn = @h3.connect("example.com", port=443, tls=tls)
let (response, body) = @spec.call(conn, @spec.Request::new("GET", "/"))
println(conn.kind())      // h3
println(response.status)  // 200
conn.close()
h3 200

Settings go out on a control stream with the QPACK dynamic table shut, so no reply can refer to a table entry this side does not have. A peer that says nothing for thirty seconds is given up on rather than waited for.

WebSocket

The upgrade is the exchange: open sends the handshake, and response is the 101 with its accept checked against the key this side offered. After that a read yields whole messages, with fragments put back together.

ws.mbt
let ws = @ws.connect("wss://example.com/chat", tls=tls)
let stream = ws.open(@spec.Request::new("GET", "/chat"))
println(ws.response(stream).status)   // 101

ws.send_text("hello")
match ws.next() {
  Some(@frame.Message::Text(reply)) => println(reply)   // hello
  Some(@frame.Message::Close(status~, ..)) => println("closed \{status}")
  _ => ()
}
101 hello

A ping from the server is answered on the way to the next message and never reaches the caller. send writes a binary message, because the contract's octets do not say whether they are text; send_text is the one that writes text. A close keeps its code and reason in close_code and close_reason.

The pool

A connection is reused only for what it was opened for, so the key is the host, the port and the protocol ALPN settled on. Every knob is a field of Config with a published preset, so changing one does not mean guessing the rest.

pool.mbt
let pool = @pool.Pool::new(
  key => @tcp.dial(key.host, port=key.port, tls=tls_for(key)),
  config={ ..@pool.defaults, idle: 60000 },
)
let key = @pool.Key::new("example.com", 443, alpn="h2")
let conn = pool.get(key)
// ... one connection's worth of work ...
pool.put(key, conn)
FieldPresetWhat it decides
idle30000 mshow long a connection may sit idle before it is closed instead of reused
max_idle8idle connections kept per key
max_total64connections held open at once, in use or idle
livenessPeekwhat a connection is asked before reuse: nothing, or a read that does not wait
wait5000 mshow long a caller waits for a slot when the pool is full

Peek is a read with a one-millisecond bound. A peer that has closed while the connection sat idle is dropped; a peer that said something is kept along with what it said, so the next exchange starts from those octets rather than after them. A pool at max_total raises Busy instead of opening past it, and a caller that waits is woken when a connection comes back.

Datagrams and QUIC

The datagram socket is a socket: bind it, send to an address, receive a datagram with its sender. QUIC sits on one, and step is one turn of the connection — put what is queued on the wire, take one datagram in.

quic.mbt
let quic = @udp.dial("example.com", port=443, tls=tls)
let stream = quic.open_bi()
quic.write(stream, b"hello", fin=true)

while quic.step(timeout=200) {
  let arrived = quic.take(stream)
  if arrived.length() > 0 {
    println(arrived)
    break
  }
  if quic.ended(stream) {
    break
  }
}

Reading is a poll rather than a blocking call because one QUIC connection carries control and QPACK streams beside the exchange's own; a blocking read would hold those back. The same socket is what HTTP/3 is built on.

Backends and errors

The sockets are the async runtime's, and they exist on the native and wasm backends. On js and wasm-gc the packages that drive them are empty, so code that only uses the front door still compiles there and a program that opens sockets is native or wasm.

Everything a connection will not do is a Refused, so a caller can tell the cases apart without matching on strings:

VariantWhen
Malformed(String)the peer's bytes do not parse: a head that never ends, a chunk size that is not a number, a body that stops short
Unsupported(String)a scheme, a version or an exchange this connection will not carry
Closedthe connection is gone, or the exchange has already ended
Broken(String)the socket failed under it

One task reads a connection and one task writes it. The async runtime aborts the process when two tasks read or two tasks write the same handle at once, so a pool hands a connection to one caller at a time.