slate

slate:net

TCP, and TLS at both ends.

import { listen, connect, onData, onError, send, close, localPort, startTls } from slate:net

val server = listen(0, conn ->
    onData(conn, chunk ->
        if chunk == null
            close(conn)
        else
            send(conn, "echo: " + chunk)))

async main()
    val client = (await connect("127.0.0.1", localPort(server))).value

    onData(client, chunk ->
        if chunk != null
            print(chunk)
            close(client)
            close(server))

    await send(client, "hello")

main()
listen(port, fn) or listen({ port, cert, key, alpn }, fn)a server
connect(host, port)a promise of a result
onData(sock, fn)each chunk as text, and null at the end
onBytes(sock, fn)each chunk as bytes, for something that is not text
onError(sock, fn)a sentence, instead of that null
send(sock, v)a promise of a result; bytes or an array of numbers go as bytes
shutdown(sock)a promise of a result, once this end has said it will write no more
close(sock)
sock.dispose()close under the name using looks for
localPort(server)
remoteAddress(conn)the IP at the other end, or null where there is no other end
startTls(sock, options)a promise that settles when the handshake finishes
alpnProtocol(conn)which application protocol was agreed, or null

Under slate js this is node’s own TCP, and everything above is the same, startTls included at both ends, listen given a cert and a key and listen(socket, fn) too. The one difference is that a secured listener there calls fn once a connection’s handshake has finished, where the interpreter hands the connection over first. A browser has no sockets at all and says so.

Promises and callbacks

connect and send answer promises of a result; listen, onData and onBytes take callbacks. That split is not a matter of taste: a connect and a send are each one attempt with one outcome, which is what a promise is, and a listener does not have one connection any more than a connection has one chunk.

A refused connection is the ordinary thing a client handles, so it comes back as { ok: false, error: … } rather than stopping the program.

onData hands over null once the peer has finished sending — the end of a stream is a value here rather than a failure, because a peer closing its half is how a request ordinarily ends. Calling either reader a second time replaces the function rather than adding one, which is what makes a protocol that changes what it expects halfway through writable at all.

A socket that fails ends its stream — it does not stop the program. A connection reset is something any peer can produce, so it arrives as the same null a peer that finished sends: if chunk == null then close(c) is right either way. onError is how a program tells the two apart, and one that never registers a handler never hears about it. It is a registration of its own rather than a third argument to onData because onData replaces its callback.

Ports and names

A port of 0 asks the kernel to pick one and localPort says which.

remoteAddress says who connected, as the text a person would read it as — "127.0.0.1", "::1". A listener has no other end and answers null, which is an answer rather than a failure: asking a socket who is at the far end of it is asking about a thing that may not be there.

An IPv4 client of a dual-stack server is written the way it dialled. listen binds :: so that a client asking for localhost reaches the server, and the kernel then reports every IPv4 peer in the mapped form ::ffff:127.0.0.1. The prefix is taken off here, or an allow-list written against 127.0.0.1 would match nothing on a machine with IPv6.

connect takes a name or an address, resolving through libuv’s resolver on its thread pool. A name that does not resolve settles the promise the way a refused connection does, rather than raising.

reusePort: true asks several processes to share one port, so the kernel spreads accepted connections across every one of them rather than giving the port to whichever process asked first — the foundation of a worker cluster, one process per core all listening on the port a load balancer sends traffic to:

val server = listen({ port: 3000, reusePort: true }, conn -> conn)

A kernel that does not distribute this way REFUSES the flag rather than doing something else with it. Two answers are correct depending on the machine, and both say so by name rather than leaving a program to guess: a kernel that distributes lets every listener bind, and one that does not answers ENOTSUP — never a bind that quietly succeeds without sharing anything, which would be a server that believes it has a share of the port and does not. macOS is one of the kernels that refuses:

import { listen } from slate:net

try
    listen({ port: 0, reusePort: true }, conn -> conn)
    print("shared")
catch e
    print(e.message.contains("ENOTSUP"))
true

Left out, reusePort is false, which is a port belonging to one listener — the ordinary case and the one every program already written keeps. reusePort is true or false and nothing else; anything else is refused by name, the way every other option here is.

listen(socket, fn) accepts on a listening socket another program passed over a channel — the other way for a supervisor to share a port, handing each worker the listener rather than each connection (slate:process has the channel). The socket arrives already bound and listening, so there is no port to choose and nothing to secure; localPort answers the port it is on, close stops this program accepting on it, and a socket that is not a passed listener is refused.

Lifetime

A socket keeps the program alive, exactly as a timer does, so close is not optional; a program that leaves one open never exits. Closing twice is fine, which is what lets a server close its connections while a read callback closes its own.

shutdown is a half-close: the write side ends (a TCP FIN, node’s socket.end()) and the socket is still read until the peer ends too, so a client can send a request, shutdown, and wait for an answer from a server that reads to the end of the stream. Whatever was sent before it goes out first. After it, send refuses with a result whose error says this end has shut down its writing; a second shutdown answers ok again; and close is still how the socket is let go. On a secured socket the TLS close-notify is written before the FIN. A listener has no writing side and shutdown answers an error for one.

A closed socket is not whatever opens next. The value a program holds carries the generation that claimed its slot, so a socket held across a close never comes to mean its successor.

A connection may have been ACCEPTED BY ANOTHER PROGRAM. child.send(value, socket) and channel().send(value, socket) in slate:process pass one over a worker’s channel, and what arrives at the other end is an ordinary connection of this module’s — onData, send, close, remoteAddress and startTls all read it as they read one this program accepted itself. The sending side’s own copy is closed once the message has gone, so exactly one program is holding it.

TLS

A listener told a certificate hands out connections whose bytes are already decrypted, so slate:http — and any other protocol written over these sockets — is an HTTPS server without learning that TLS exists:

val server = listen({ port: 8443, cert: pem, key: keyPem }, conn -> …)

TLS is a transport and not an HTTP concern, which is why it goes under listen rather than beside serve. node splits it the other way, with an https module beside http; node’s https is literally http over a tls.Server, and putting the seam one layer down is that arrangement with the duplication left out. alpn beside them offers a protocol list.

The other end is startTls, which upgrades a socket that is already open:

async main()
    val db = (await connect("db.example.com", 5432)).value

    onBytes(db, read)

    val up = await startTls(db, { host: "db.example.com", trust: authority })

    if !up.ok then print(up.error)

ALPN, which is how HTTP/2 is chosen

A client offers a list of protocol names and the server picks one, and for HTTP/2 that is the whole of the negotiation — there is no upgrade handshake and no version header. Both ends say what they will take, and both read the answer off the connection:

val server = listen({ port: 8443, cert: pem, key: keyPem, alpn: ["h2", "http/1.1"] }, conn ->
    if alpnProtocol(conn) == "h2" then speakHttp2(conn) else speakHttp1(conn))

val up = await startTls(sock, { host: "example.com", alpn: ["h2", "http/1.1"] })

print(alpnProtocol(sock))

The server’s own order decides, because it is the end that knows which protocol it serves well. alpnProtocol answers null for three ordinary cases and no failure: a plain socket, a peer that offered nothing, and a handshake that has not finished — a server reads it inside its connection callback, by which time the handshake is long done.

slate:nghttp2 is what frames the bytes once h2 is what was agreed.

An upgrade rather than a flag on connect, because that is what protocols actually do. TLS from byte zero is one case and not the general one: PostgreSQL sends eight bytes in the clear and reads one back before any handshake, SMTP has STARTTLS, IMAP and FTP have their own, and a WebSocket over TLS is HTTPS first. It is also the weaker primitive — TLS from the start is connect and then this — so one builtin covers both where a flag would have covered neither.

A server upgrades its end the same way, with the certificate and key listen would have taken:

startTls(conn, { cert: pem, key: keyPem })

There is no third argument saying which end this is: a client has a name to check and a server has a certificate to present, and neither object could be mistaken for the other.

Everything after the handshake is unchanged: send takes plaintext, the reader is handed plaintext, and a program that writes before awaiting has its bytes queued rather than lost.

The name is checked against the certificate and there is no way to say otherwise. It goes out as SNI, without which a shared host does not know which certificate to send, and it is what the certificate’s names are verified against. An address works too and is checked against the certificate’s addresses rather than its names — which is why connect resolving matters here rather than being a convenience: verify against the name the program was looking for, not the address the resolver happened to answer with.

trust is added to the machine’s own store rather than put in place of it, so naming a private authority does not stop verifying everything else the program talks to.

Search

Esc
↑↓ to navigate ↵ to open Esc to close