Current section

Files

Jump to
tup README.md
Raw

README.md

# tup

A TCP and TLS acceptor pool for Gleam, built on `relay_supervisor`.

[![Package Version](https://img.shields.io/hexpm/v/tup)](https://hex.pm/packages/tup)
[![Hex Docs](https://img.shields.io/badge/hex-docs-ffaff3)](https://hexdocs.pm/tup/)

## Contents

- [Installation](#installation)
- [Usage](#usage)
  - [Getting Started](#getting-started)
  - [Custom Messages](#custom-messages)
  - [Flow Control](#flow-control)
  - [Who Is Connected](#who-is-connected)
  - [TLS](#tls)
  - [Unix Sockets](#unix-sockets)
  - [IPv6](#ipv6)
  - [Graceful Shutdown](#graceful-shutdown)
  - [Controlling a Running Server](#controlling-a-running-server)
  - [Memory and Throughput](#memory-and-throughput)
  - [Running Under Supervision](#running-under-supervision)
  - [Running as an OTP Application](#running-as-an-otp-application)
- [API Reference](#api-reference)

<h2 id="installation">Installation</h2>

```sh
gleam add tup@1 gleam_erlang gleam_otp
```

<h2 id="usage">Usage</h2>

<h3 id="getting-started">Getting Started</h3>

You describe a server with [`tup.new`](https://hexdocs.pm/tup/tup.html#new) and
run it with [`tup.start`](https://hexdocs.pm/tup/tup.html#start). `tup.new`
takes three callbacks, all of which run in the connection's own process:

- `on_init` is called once when the connection is set up and returns the state 
  the connection starts with.
- `handler` is called for every message and decides what happens next by
  returning [`tup.continue`](https://hexdocs.pm/tup/tup.html#continue),
  [`tup.stop`](https://hexdocs.pm/tup/tup.html#stop) or
  [`tup.stop_abnormal`](https://hexdocs.pm/tup/tup.html#stop_abnormal).
- `on_close` is called once the connection has ended.

Here is the example with a simple echo server.

```gleam
import gleam/bytes_tree
import gleam/erlang/process
import tup

pub fn main() {
  let assert Ok(_started) =
    tup.new(
      on_init: fn(_connection, selector) { #(Nil, selector) },
      handler: fn(connection, state, message) {
        case message {
          tup.Incoming(data) -> {
            let _sent = tup.send(connection, bytes_tree.from_bit_array(data))
            tup.continue(state)
          }
          tup.User(_message) -> tup.continue(state)
        }
      },
      on_close: fn(_state) { Nil },
    )
    |> tup.listening(on: tup.Tcp("0.0.0.0", 3000))
    |> tup.start

  process.sleep_forever()
}
```

[`tup.listening`](https://hexdocs.pm/tup/tup.html#listening) takes a
[`tup.Address`](https://hexdocs.pm/tup/tup.html#Address). Using port `0`, as in
`Tcp("0.0.0.0", 0)`, lets the system pick a free port. The default is
`Tcp("127.0.0.1", 3000)`.

[`tup.start`](https://hexdocs.pm/tup/tup.html#start) hands back the endpoint the
server listens on.

```gleam
let assert Ok(actor.Started(data: endpoint, ..)) =
  tup.new(on_init:, handler:, on_close:)
  |> tup.listening(on: tup.Tcp("127.0.0.1", 0))
  |> tup.start

tup.endpoint_to_string(endpoint)
// -> "127.0.0.1:54321"
```

[`tup.pool_size`](https://hexdocs.pm/tup/tup.html#pool_size) sets how many
acceptors wait for connections at the same time. The default is 20.

<h3 id="custom-messages">Custom Messages</h3>

`on_init` receives an empty selector, which you may use to 
[`tup.User`](https://hexdocs.pm/tup/tup.html#Message) messages to the connection.

```gleam
tup.new(
  on_init: fn(_connection, selector) {
    let client = process.new_subject()
    pubsub.subscribe(pubsub, client)

    #(client, process.select(selector, client))
  },
  handler: fn(connection, client, message) {
    case message {
      tup.User(broadcast) -> {
        let _sent = tup.send(connection, bytes_tree.from_string(broadcast))
        tup.continue(client)
      }
      tup.Incoming(_data) -> tup.continue(client)
    }
  },
  on_close: fn(client) { pubsub.unsubscribe(pubsub, client) },
)
```

To change the selector later, return the new selector with
[`tup.with_selector`](https://hexdocs.pm/tup/tup.html#with_selector):

```gleam
tup.continue(state)
|> tup.with_selector(selector)
```

<h3 id="flow-control">Flow Control</h3>

By default, a connection reads one packet at a time and only asks for the next
one once the handler has run. This way a client can never fill the connection's
mailbox faster than it is served.
[`tup.active_state`](https://hexdocs.pm/tup/tup.html#active_state) changes this
for every connection and
[`tup.with_active_state`](https://hexdocs.pm/tup/tup.html#with_active_state)
changes it for a single connection from the next message onwards.

| [`tup.ActiveState`](https://hexdocs.pm/tup/tup.html#ActiveState) | What it does |
| --- | --- |
| `Once` | One packet then the connection asks for the next. This is the default. |
| `Count(n)` | `n` packets then the connection asks for more. |
| `Active` | Every packet as soon as it arrives with no back pressure. |

```gleam
tup.continue(state)
|> tup.with_active_state(tup.Count(100))
```

<h3 id="who-is-connected">Who Is Connected</h3>

[`tup.peer`](https://hexdocs.pm/tup/tup.html#peer) and
[`tup.local`](https://hexdocs.pm/tup/tup.html#local) give you the two ends of a
connection as a [`tup.Endpoint`](https://hexdocs.pm/tup/tup.html#Endpoint). You 
can format the endpoint with
[`tup.endpoint_to_string`](https://hexdocs.pm/tup/tup.html#endpoint_to_string).

```gleam
fn describe(connection: tup.Connection) -> String {
  case tup.peer(connection) {
    tup.TcpEndpoint(ip_address:, port: _) ->
      // An IPv4 client on a dual-stack socket shows up as an IPv4-mapped IPv6
      // address so unmap it before comparing or logging it.
      tup.unmap_ipv4(ip_address)
      |> tup.ip_address_to_string
    tup.UnixEndpoint(path:) -> "unix:" <> path
  }
}
```

<h3 id="tls">TLS</h3>

[`tup.with_tls`](https://hexdocs.pm/tup/tup.html#with_tls) takes a
[`tup.Tls`](https://hexdocs.pm/tup/tup.html#Tls), which you build from a
[`tup.Certificate`](https://hexdocs.pm/tup/tup.html#Certificate). The
certificate and key are read and decoded before the server starts.

```gleam
tup.new(on_init:, handler:, on_close:)
|> tup.listening(on: tup.Tcp("0.0.0.0", 8443))
// Certificate and key files on disk.
|> tup.with_tls(tup.tls(tup.Disk("priv/localhost.crt", "priv/localhost.key")))
// Or encrypted on disk: tup.EncryptedDisk(cert, key, "password")
// Or PEM in memory:     tup.Pem(cert, key)
// Or DER in memory:     tup.Der([cert], tup.RsaPrivateKey(key))
|> tup.start
```

To request or require a client certificate, add
[`tup.verifying_clients`](https://hexdocs.pm/tup/tup.html#verifying_clients).
[`tup.with_alpn`](https://hexdocs.pm/tup/tup.html#with_alpn) offers protocols
during the handshake, and
[`tup.session_tickets`](https://hexdocs.pm/tup/tup.html#session_tickets)
controls how session resumption tickets are handled.

```gleam
tup.tls(tup.Disk("priv/localhost.crt", "priv/localhost.key"))
|> tup.verifying_clients(tup.Required(trusting: tup.TrustDisk("priv/ca.crt")))
|> tup.with_alpn(["h2", "http/1.1"])
|> tup.handshake_timeout(5000)
```

A handshake has 10 seconds to finish before the connection is closed.
[`tup.handshake_timeout`](https://hexdocs.pm/tup/tup.html#handshake_timeout)
changes that and
[`tup.infinite_handshake_timeout`](https://hexdocs.pm/tup/tup.html#infinite_handshake_timeout)
removes the limit.

<h3 id="unix-sockets">Unix Sockets</h3>

```gleam
|> tup.listening(on: tup.Unix("/tmp/app.sock"))
```

The path must not be empty, must be at most 107 bytes long and must not contain
a NUL byte. All of this is checked before the server starts.

A socket file left behind by a previous run is removed on start. If the path
holds anything else, such as a directory or a regular file, starting the server
returns an `Error` instead, and nothing is deleted.

The file stays on disk after the server stops, and connecting to it then gives
`econnrefused` until the next start clears it. Its permissions come from the
process umask, so the way to control access is through the permissions of the
directory that holds it.

[`tup.local`](https://hexdocs.pm/tup/tup.html#local) and
[`tup.listen_endpoint`](https://hexdocs.pm/tup/tup.html#listen_endpoint) return
`UnixEndpoint(path)`. `tup.peer` returns `UnixEndpoint("")`, since a client of a
unix socket has no address of its own.

<h3 id="ipv6">IPv6</h3>

An address written in IPv6 notation listens on IPv6: `"::1"` is the loopback
address and `"::"` means every interface. A `"::"` socket also accepts IPv4
clients, whose addresses arrive as IPv4-mapped IPv6 addresses that
[`tup.unmap_ipv4`](https://hexdocs.pm/tup/tup.html#unmap_ipv4) turns back into
IPv4.

`"0.0.0.0"` and `"localhost"` name IPv4 interfaces.
[`tup.force_ipv6`](https://hexdocs.pm/tup/tup.html#force_ipv6) makes them mean
every IPv6 interface and `"::1"` instead and refuses any IPv4 clients.

```gleam
|> tup.listening(on: tup.Tcp("0.0.0.0", 3000))
|> tup.force_ipv6
```

Combining it with any other IPv4 address, or with a unix socket path, makes
[`tup.start`](https://hexdocs.pm/tup/tup.html#start) return an `Error`.

<h3 id="graceful-shutdown">Graceful Shutdown</h3>

When the server shuts down, the acceptors stop and the listen socket closes
before any connection is asked to finish. Each connection then finishes the
message it is handling, runs `on_shutdown` and ends with `on_close`.

[`tup.on_shutdown`](https://hexdocs.pm/tup/tup.html#on_shutdown) is where a
protocol says goodbye, for example with a WebSocket close frame or an HTTP/2
GOAWAY.

```gleam
tup.new(on_init:, handler:, on_close:)
|> tup.on_shutdown(fn(connection, _state) {
  let _sent = tup.send(connection, bytes_tree.from_string("bye\n"))
  Nil
})
// How long a connection may take to finish. 15 seconds by default.
|> tup.shutdown_timeout(30_000)
```

A connection still running when the deadline passes is killed and neither
callback gets to finish.
[`tup.infinite_shutdown_timeout`](https://hexdocs.pm/tup/tup.html#infinite_shutdown_timeout)
waits for as long as it takes, which only makes sense when every handler is
guaranteed to return.

> [!NOTE]
> All of this happens when OTP shuts the server down, so the server has to be
> part of a supervision tree for a signal such as SIGTERM to reach it. See
> [Running as an OTP Application](#running-as-an-otp-application). If the server
> is started straight from `main`, the VM exits without any of this running.

<h3 id="controlling-a-running-server">Controlling a Running Server</h3>

Give the server a name with [`tup.named`](https://hexdocs.pm/tup/tup.html#named)
and the rest of your program can query it.

```gleam
let name = process.new_name("tup_server")

let assert Ok(_started) =
  tup.new(on_init:, handler:, on_close:)
  |> tup.named(name)
  |> tup.listening(on: tup.Tcp("0.0.0.0", 0))
  |> tup.start

// Stop accepting and close the listen socket, leaving open connections running.
let assert Ok(Nil) = tup.suspend(name)
let assert Ok(open) = tup.connection_count(name)

// Start accepting again.
let assert Ok(Nil) = tup.resume(name)

// Where it listens now.
let assert Ok(endpoint) = tup.listen_endpoint(name, within: 1000)
```

If the server was bound to port 0,
[`tup.resume`](https://hexdocs.pm/tup/tup.html#resume) binds to a new port, use 
[`tup.listen_endpoint`](https://hexdocs.pm/tup/tup.html#listen_endpoint) to find 
the new port.

<h3 id="memory-and-throughput">Memory and Throughput</h3>

Each connection costs about 27 KB of memory and a good part of it the read 
buffer which Erlang sizes at about 9 KB.
[`tup.buffer_size`](https://hexdocs.pm/tup/tup.html#buffer_size) lets you trade
memory for throughput.

```gleam
|> tup.buffer_size(131_072)
```

<h3 id="running-under-supervision">Running Under Supervision</h3>

[`tup.start`](https://hexdocs.pm/tup/tup.html#start) runs the server on its own.
To run it as part of a supervision tree alongside the rest of your program, use
[`tup.supervised`](https://hexdocs.pm/tup/tup.html#supervised) instead, which
returns a child specification.

```gleam
supervisor.new(supervisor.OneForOne)
|> supervisor.add(pubsub.worker(pubsub_name))
|> supervisor.add(
  tup.new(on_init:, handler:, on_close:)
  |> tup.listening(on: tup.Tcp("0.0.0.0", 3000))
  |> tup.supervised,
)
|> supervisor.start
```

<h3 id="running-as-an-otp-application">Running as an OTP Application</h3>

Starting the server from `main` with a `let assert` is the shortest thing that
works while you are trying tup out. A real service is better off letting the
[OTP application](https://www.erlang.org/doc/apps/kernel/application.html)
controller own the supervision tree: it brings the tree down in order on
shutdown, which is what makes [graceful shutdown](#graceful-shutdown) happen on
SIGTERM.

Point `application_start_module` in your `gleam.toml` at a module that exports
`start/2` and `stop/1`:

```toml
[erlang]
application_start_module = "my_app"
```

```gleam
import gleam/erlang/atom
import gleam/erlang/process
import gleam/otp/actor
import gleam/otp/static_supervisor as supervisor
import tup

/// The Erlang/OTP application start callback. Starts the top supervisor and
/// hands its pid back to the application controller.
pub fn start(_type: a, _args: b) -> Result(process.Pid, actor.StartError) {
  case
    supervisor.new(supervisor.OneForOne)
    |> supervisor.add(
      tup.new(on_init:, handler:, on_close:)
      |> tup.listening(on: tup.Tcp("0.0.0.0", 3000))
      |> tup.supervised,
    )
    |> supervisor.start
  {
    Ok(actor.Started(pid:, ..)) -> Ok(pid)
    Error(reason) -> Error(reason)
  }
}

/// Called once every process in the tree is down.
pub fn stop(_state: a) -> atom.Atom {
  atom.create("ok")
}

/// The application is already running by the time this is called, so all that
/// is left for `main` to do is keep the node alive.
pub fn main() {
  process.sleep_forever()
}
```

<h2 id="api-reference">API Reference</h2>

For the full API documentation, see [hexdocs.pm/tup](https://hexdocs.pm/tup/tup.html).