std.net
Raw TCP and TLS sockets. Needs net.
import std.io
import std.net
c = net.connect("example.com", 80)
io.write(c, bytes("GET / HTTP/1.0\r\n\r\n"))
data = io.read(c) # bytes
io.close(c)
c = net.connect_tls("example.com", 443)
c = net.starttls(c) # upgrade an open connection
A connection is a stream, so the verbs that read a socket are the same ones that read a file, a child's pipe or standard input. net gives you the socket; std.io does the talking.
Reading
| call | |
|---|---|
io.read(c) | whatever is available, as bytes |
io.read_text(c) | the same, decoded as UTF-8 |
io.read_exact(c, n) | exactly n bytes |
io.read_until(c, delim) | up to and including a delimiter |
A read returns whatever arrived, not a whole message. TCP is a byte stream with no message boundaries, so one io.read may give you half a response or two responses. This is the classic source of protocol bugs.
One socket is a conversation, not a duplex pipe. The connection is behind a single lock covering reads and writes, so a read that is waiting blocks a write on the same socket until it finishes or times out. Every protocol in this module's own clients is request and response, which is why that is the right trade. To send while a read is parked, open a second connection or use std.ws.
Use io.read_exact for a length-prefixed protocol and io.read_until for a line-delimited one - those two cover almost everything, and both handle the framing you would otherwise get wrong. io.read_exact errors rather than returning a short read, and what it managed to read stays readable.
Deadlines
A socket starts with the read and write deadline ECKO_NET_TIMEOUT_MS gives it, 30 seconds by default, so a peer that accepts a connection and then says nothing surfaces an error instead of hanging. io.timeout(c, ms) changes it for one socket, and io.timeout(c, null) waits as long as it takes - which is what you want for a socket that legitimately sits waiting on server push.
A deadline that passes is an error, not an empty read. The end of the stream is null and nothing else is, so a loop reading until null cannot mistake a stalled peer for a closed one.
Accepting connections
import std.io
import std.net
l = net.listen(0) # 0 asks the OS for a free port
print(net.port(l)) # which one it chose
conn = net.accept(l) # blocks until a client arrives
io.write(conn, "hello\r\n")
io.close(conn)
net.stop(l)
net.accept answers null when nothing arrived before its deadline, so a polling loop reads naturally:
loop {
conn = net.accept(l, 1000)
if is_null(conn) { continue }
handle(conn)
}
That is the opposite of a stream read, where null means the end - a listener has no end short of net.stop, which makes a later accept an error rather than a null, so a loop cannot spin on a listener that is gone.
A listener is a handle rather than a stream, because reading it yields connections and not bytes. net.stop closes it and is idempotent.
An accepted connection is an ordinary stream, read and written with the same io verbs. starttls refuses it: TLS on the accepting side presents a certificate, where starttls is the connecting side and verifies one.
lookup
net.lookup(host) resolves a hostname to a list of IP address strings via the OS resolver - the same lookup connect does internally, exposed on its own for when you want the address without opening a socket.
Names with several addresses
connect and connect_tls try every address a name resolves to, in order, and use the first that answers. That matters on a dual-stack host, where localhost usually resolves to ::1 before 127.0.0.1: a server listening only on IPv4 is still reachable by name. The 30-second budget covers the whole call rather than each attempt.
starttls
Upgrades an established plaintext connection, which is what SMTP, IMAP and PostgreSQL do, and returns the same stream now speaking TLS. connect_tls is for a connection that is encrypted from the start.
The certificate is verified against the host you passed to net.connect, not the address it resolved to - certificates are issued for names, so checking the IP would check the wrong thing.
An upgrade is refused while plaintext you read ahead is still buffered in the stream. Those bytes arrived before the handshake, so they cannot be part of the encrypted session, and silently dropping them would corrupt the protocol at exactly the point that is hardest to debug.
Why this exists
So that a protocol client can be written in Ecko, with no native code. The official MySQL, PostgreSQL, Redis and SMTP clients are all built on this module - real wire protocols, authentication scrambles and all, in the language itself.
That is the three-layer policy working: rather than adding a database driver to the runtime, the runtime provides sockets and the driver is an ordinary, capability-gated, replaceable package.
Use a client, not this
For talking to a database or a mail server, use the package:
Reach for net when you are implementing a protocol that does not have a client yet.
Moving from the old verbs
net.send, net.recv, net.recv_text, net.recv_exact, net.recv_until and net.close were removed in 0.23. Each has a one-for-one replacement, and ecko check names it for you:
| was | now |
|---|---|
net.send(c, data) | io.write(c, data) |
net.recv(c, n) | io.read(c, n) |
net.recv_text(c, n) | io.read_text(c, n) |
net.recv_exact(c, n) | io.read_exact(c, n) |
net.recv_until(c, delim) | io.read_until(c, delim) |
net.close(c) | io.close(c) |
UDP datagrams
A datagram is a message, not a stream, so UDP has verbs of its own:
import std.net
sock = net.udp_bind("0.0.0.0", 0) # port 0: the OS picks one
net.send_to(sock, "127.0.0.1", 9000, "ping")
msg = net.recv_from(sock) # one datagram
print(string(msg.data)) # ping
print(msg.host) # who sent it
print(msg.port) # and from which port
net.udp_close(sock)
| call | |
|---|---|
net.udp_bind(host, port) | a socket; port 0 asks the OS to choose one |
net.port(sock) | which port it actually got |
net.send_to(sock, host, port, data) | bytes written |
net.recv_from(sock) | { data, host, port } for one datagram |
net.timeout(sock, ms) | read and write deadline; 0 disables |
net.broadcast(sock, on) | allow sending to a broadcast address |
net.udp_close(sock) | release it |
data is bytes, and send_to takes bytes or a string. The maximum payload is 65507 bytes and the receive buffer is larger, so a datagram is never truncated.
A UDP socket is not a stream, on purpose. The std.io verbs join consecutive chunks - io.read_exact(c, 10) will happily take five bytes from one read and five from the next, which is correct for a file or a TCP connection. For datagrams it is not: two unrelated packets would be spliced into one value and nothing could tell. Keeping the socket out of the stream model makes that impossible rather than merely discouraged, at the cost of not reusing those verbs. That trade is deliberate - one value that behaves two different ways is a worse promise than two honest kinds.
A deadline raises, it does not return null. null means end of stream, and a datagram socket has no end, so there is nothing for it to mean here. A recv_from that passes its deadline raises socket: timed out, catchable with try. New sockets start with the deadline ECKO_NET_TIMEOUT_MS gives a TCP socket, so one setting governs both.
Multicast group membership is not here: it needs interface selection and, for IPv6, scope identifiers. net.broadcast covers ordinary LAN discovery.
Errors and timeouts
Failures raise { kind: "net", host } while connecting, and { kind: "io" } once you are reading or writing the stream. Bound your own protocol logic, and do not expose an unbounded socket loop to untrusted peers.
A UDP failure raises { kind: "net" } throughout, since there is no stream to hand over to.