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.
moon add moonbitstack/moonnet
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.
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()
})
}
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.
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
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.
let opened = @moonnet.connect("https://example.com/status", alpn=["h2", "http/1.1"])
println(opened.alpn) // h2
println(opened.target) // /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.
| Method | What 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.
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.
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/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.
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.
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()
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.
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}")
_ => ()
}
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.
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)
| Field | Preset | What it decides |
|---|---|---|
idle | 30000 ms | how long a connection may sit idle before it is closed instead of reused |
max_idle | 8 | idle connections kept per key |
max_total | 64 | connections held open at once, in use or idle |
liveness | Peek | what a connection is asked before reuse: nothing, or a read that does not wait |
wait | 5000 ms | how 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.
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:
| Variant | When |
|---|---|
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 |
Closed | the 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.