ewe

🐑 ewe

ewe [/juː/] - fluffy HTTP/1 and HTTP/2 web server for Gleam.

Package Version Hex Docs

Contents

Most section headings are links, each one opening the runnable example it is based on.

Installation

gleam add ewe@5 gleam_erlang gleam_otp gleam_http logging

Usage

Getting Started

A handler takes a request.Request(ewe.Connection) and returns a response.Response(ewe.Body). The connection carried by the request is what ewe.read_body, ewe.file and ewe.websocket work on.

Instead of a port you can bind a unix domain socket with ewe.unix, or let the OS pick a free port with ewe.listening_random and ask for the one it picked with ewe.get_server_info.

import ewe
import gleam/erlang/process
import gleam/http/request
import gleam/http/response
import logging

pub fn main() {
  logging.configure()
  logging.set_level(logging.Info)

  // The acceptor pool wires the listener and the connection factory together
  // through process names. Create them where your program starts and pass them
  // in here.
  //
  let listener_name = process.new_name("listener_name")
  let connection_factory_name = process.new_name("connection_factory_name")

  let assert Ok(_) =
    ewe.new(listener_name:, connection_factory_name:, handler: handle_request)
    |> ewe.bind(to: "0.0.0.0")
    |> ewe.listening(on: 8080)
    |> ewe.start

  process.sleep_forever()
}

fn handle_request(
  _request: request.Request(ewe.Connection),
) -> response.Response(ewe.Body) {
  // When sending a body it is important to include a `content-type` header.
  // You never set `content-length` or `transfer-encoding` yourself, ewe frames
  // the response and writes them for you.
  //
  response.new(200)
  |> response.set_header("content-type", "text/plain; charset=utf-8")
  |> response.set_body(ewe.Text("Hello, World!"))
}

HTTPS

Enable TLS with ewe.with_tls, which takes the certificate source as a ewe.Tls value. The certificate and key are validated on startup and the server crashes if they are missing or invalid.

ewe.new(listener_name:, connection_factory_name:, handler: handle_request)
|> ewe.bind(to: "0.0.0.0")
|> ewe.listening(on: 8080)
// Certificate and key files on disk.
|> ewe.with_tls(ewe.Disk("priv/localhost.crt", "priv/localhost.key"))
// Or PEM already in memory: ewe.Pem(cert, key)
// Or DER in memory:         ewe.Der(cert, key, ewe.RsaPrivateKey)
|> ewe.start

To refuse clients that do not present a certificate signed by an authority you name, add ewe.with_client_verification. It needs TLS to be configured.

|> ewe.with_tls(ewe.Disk("priv/localhost.crt", "priv/localhost.key"))
|> ewe.with_client_verification(ewe.CaCertFile("priv/ca.crt"))

HTTP/2

HTTP/2 is always enabled on ewe. Over TLS ewe offers it through ALPN and a plain connection is served as HTTP/2 when it opens with the HTTP/2 preface which is what a client with prior knowledge sends. An Upgrade: h2c request is not negotiated, it is answered as HTTP/1.1.

Extended CONNECT is not negotiated yet, so WebSockets over HTTP/2 are not supported.

Sending a Response

A response body is one of the ewe.Body variants. Text, Bytes and Empty are built by hand, the rest come from ewe.file, ewe.stream_response, ewe.sse and ewe.websocket.

import ewe
import gleam/bytes_tree
import gleam/crypto
import gleam/http/request
import gleam/http/response
import gleam/int
import gleam/result

fn handle_request(
  request: request.Request(ewe.Connection),
) -> response.Response(ewe.Body) {
  case request.path_segments(request) {
    ["hello", name] -> {
      // Text for text responses.
      response.new(200)
      |> response.set_header("content-type", "text/plain; charset=utf-8")
      |> response.set_body(ewe.Text("Hello, " <> name <> "!"))
    }
    ["bytes", amount] -> {
      // Bytes for binary responses built from a `BytesTree`.
      let body =
        int.parse(amount)
        |> result.unwrap(0)
        |> crypto.strong_random_bytes
        |> bytes_tree.from_bit_array
        |> ewe.Bytes

      response.new(200)
      |> response.set_header("content-type", "application/octet-stream")
      |> response.set_body(body)
    }
    _segments ->
      // Empty for responses with no body like 404 or 204.
      response.new(404)
      |> response.set_body(ewe.Empty)
  }
}

Reading the Request Body

ewe.read_body reads the whole body into memory up to limit bytes. Trailer fields of a chunked request are appended to the returned request’s headers.

fn handle_request(
  request: request.Request(ewe.Connection),
) -> response.Response(ewe.Body) {
  let content_type =
    request.get_header(request, "content-type")
    |> result.unwrap("application/octet-stream")

  case ewe.read_body(request, limit: 10_240) {
    Ok(req) ->
      response.new(200)
      |> response.set_header("content-type", content_type)
      |> response.set_body(ewe.Bytes(bytes_tree.from_bit_array(req.body)))
    Error(ewe.BodyTooLarge) ->
      response.new(413)
      |> response.set_header("content-type", "text/plain; charset=utf-8")
      |> response.set_body(ewe.Text("Body too large"))
    Error(ewe.InvalidBody) ->
      response.new(400)
      |> response.set_header("content-type", "text/plain; charset=utf-8")
      |> response.set_body(ewe.Text("Invalid request"))
  }
}

A body the handler never read is drained by the server so the connection can be reused. One larger than auto_drain_limit closes the connection instead.

Streaming Bodies

ewe.read_body_chunk pulls up to max_chunk_bytes per call rather than buffering everything. Each ewe.Chunk carries the request to feed into the next call.

Going the other way, ewe.stream_response turns a response into a streamed one. Its handler owns an ewe.ResponseWriter and must end by calling ewe.finish_chunk or ewe.finish_response since that is what closes the stream. The callback runs in the same connection process.

fn handle_stream(
  req: request.Request(ewe.Connection),
  max_chunk_bytes: Int,
) -> response.Response(ewe.Body) {
  let content_type =
    request.get_header(req, "content-type")
    |> result.unwrap("application/octet-stream")

  response.new(200)
  |> response.set_header("content-type", content_type)
  |> ewe.stream_response(echo_body(req, _, max_chunk_bytes))
}

// Read the request body one chunk at a time and write each one back out.
//
fn echo_body(
  req: request.Request(ewe.Connection),
  writer: ewe.ResponseWriter,
  max_chunk_bytes: Int,
) -> Result(Nil, ewe.SendError) {
  case ewe.read_body_chunk(req, max_chunk_bytes:, limit: 10_485_760) {
    Ok(ewe.Chunk(data:, request:)) -> {
      use writer <- result.try(ewe.send_chunk(writer, data))
      echo_body(request, writer, max_chunk_bytes)
    }
    Ok(ewe.Done(_request)) -> ewe.finish_response(writer)
    Error(_body_error) -> ewe.finish_response(writer)
  }
}

Serving Files

ewe.file prepares a file as a response body so you never read one in yourself. offset and limit serve a byte range, which is what a range request needs. It takes the connection so it is the request’s body you pass in first.

case ewe.file(request.body, resolved, offset: None, limit: None) {
  Ok(file) ->
    response.new(200)
    |> response.set_header("content-type", "application/octet-stream")
    |> response.set_body(file)
  Error(_error) -> not_found()
}

Client Address

ewe.get_client_info reads the address a request came from off its connection as a ewe.SocketAddress. It fails only when the socket is already gone.

fn describe_client(connection: ewe.Connection) -> String {
  case ewe.get_client_info(connection) {
    Ok(ewe.TcpSocketAddress(ip_address:, port:)) -> {
      // An IPv6 address is bracketed so the port stays readable next to the
      // colons the address itself is full of.
      let host = case ip_address {
        ewe.IpV6(..) -> "[" <> ewe.ip_address_to_string(ip_address) <> "]"
        ewe.IpV4(..) -> ewe.ip_address_to_string(ip_address)
      }

      host <> ":" <> int.to_string(port)
    }
    Ok(ewe.UnixSocketAddress(path: "")) -> "unix socket"
    Ok(ewe.UnixSocketAddress(path:)) -> "unix:" <> path
    Error(Nil) -> "unknown"
  }
}

Behind a proxy this is the proxy’s address rather than the browser’s. The one the proxy puts in x-forwarded-for is the address to use there. MDN’s security and privacy concerns is worth a read before you rely on it for anything since an address taken on trust is an address anyone can choose.

WebSocket

ewe.websocket turns a request into a WebSocket. A request that is not a valid handshake is answered with a 400 and your handler never runs. Frames from the client and messages from the rest of your program arrive as ewe.WebsocketMessage values. Answer them with ewe.send_text_frame or ewe.send_binary_frame and say what happens next with ewe.WebsocketNext.

fn handle_topic(
  req: request.Request(ewe.Connection),
  pubsub: Subject(pubsub.Message(Broadcast)),
  topic: String,
) -> response.Response(ewe.Body) {
  ewe.websocket(
    request: req,
    // Called once. The selector is where you add whatever the rest of your
    // program sends to this connection.
    on_init: fn(_conn, selector) {
      let client = process.new_subject()
      pubsub.subscribe(pubsub, topic:, client:)

      let state = WebsocketState(pubsub:, topic:, client:)
      let selector = process.select(selector, client)

      #(state, selector)
    },
    handler: handle_websocket_message,
    // Called once however the WebSocket ended.
    on_close: fn(_conn, state) {
      pubsub.unsubscribe(state.pubsub, topic: state.topic, client: state.client)
    },
  )
}

fn handle_websocket_message(
  conn: ewe.WebsocketConnection,
  state: WebsocketState,
  message: ewe.WebsocketMessage(Broadcast),
) -> ewe.WebsocketNext(WebsocketState, Broadcast) {
  case message {
    ewe.TextFrame(text) -> {
      pubsub.publish(state.pubsub, topic: state.topic, message: Text(text))
      ewe.websocket_continue(state)
    }

    ewe.BinaryFrame(data) -> {
      pubsub.publish(state.pubsub, topic: state.topic, message: Bytes(data))
      ewe.websocket_continue(state)
    }

    // A message from the rest of the program.
    ewe.UserMessage(broadcast) -> {
      let sent = case broadcast {
        Text(text) -> ewe.send_text_frame(conn, text)
        Bytes(data) -> ewe.send_binary_frame(conn, data)
      }

      case sent {
        Ok(Nil) -> ewe.websocket_continue(state)
        Error(_send_error) ->
          ewe.websocket_stop_abnormal("Failed to send a frame")
      }
    }
  }
}

Ping and pong frames are answered by the server and never reach the handler. To start the closing handshake yourself, return ewe.send_close_frame with a ewe.CloseReason. No frame can be sent after it!

Server-Sent Events

ewe.sse turns a response into an SSE stream which runs until the handler stops it or the client disconnects. on_init receives the subject the rest of your program pushes messages to, handler is called for each of those messages and on_close runs once the stream ends. The content-type and cache-control headers the stream needs are set by ewe.

response.new(200)
|> ewe.sse(
  on_init: fn(client) {
    pubsub.subscribe(pubsub, topic:, client:)

    client
  },
  handler: fn(conn, client, message) {
    case ewe.send_event(conn, ewe.event(message)) {
      Ok(Nil) -> ewe.sse_continue(client)
      Error(_send_error) -> ewe.sse_stop()
    }
  },
  on_close: fn(_conn, client) {
    pubsub.unsubscribe(pubsub, topic:, client:)
  },
)

An event is built with ewe.event and can carry a name, an id and a reconnection delay through ewe.event_name, ewe.event_id and ewe.event_retry. ewe.comment sends something clients ignore which is the usual way to keep an idle stream from being closed by a proxy.

Connection Limits and Timeouts

Every connection is held to a set of limits and timeouts. Start with ewe.default_http1_options or ewe.default_http2_options, update the fields you care about and hand the result to ewe.with_http1 or ewe.with_http2. Sizes are in bytes and timeouts in milliseconds.

let http1 =
  ewe.Http1Options(
    ..ewe.default_http1_options(),
    // Refuse a request carrying more than 50 header fields with a 431.
    max_headers: 50,
    // Close a connection that sits idle for 30 seconds.
    idle_timeout: 30_000,
  )

let http2 =
  ewe.Http2Options(
    ..ewe.default_http2_options(),
    // Cap how many streams a client may have open at once.
    max_concurrent_streams: Some(100),
    // Trip a GOAWAY sooner on a client resetting streams in bulk.
    rapid_reset_threshold: 50,
  )

ewe.new(listener_name:, connection_factory_name:, handler: handle_request)
|> ewe.with_http1(http1)
|> ewe.with_http2(http2)
|> ewe.start

ewe.Http1Options:

FieldDefaultWhat it does
max_request_line8192Longer request lines are refused with a 414.
max_header_line8192Longer header lines are refused with a 431.
max_headers100Requests carrying more header fields are refused with a 431.
max_chunk_size_line128Longest chunk size line in a chunked body.
idle_timeout10_000How long a connection may sit without sending anything.
body_read_timeout10_000How long a single body read waits for the client.
auto_drain_limit1_048_576An unread body larger than this closes the connection instead of being drained.
auto_drain_chunk_bytes65_536How much of that drain is read at a time.

ewe.Http2Options, where a value the protocol does not allow is replaced with the default rather than reaching a peer:

FieldDefaultWhat it does
max_concurrent_streamsNoneHow many streams a client may have open at once.
initial_window_size2_097_152How much response body a stream may have in flight.
max_frame_size16_384Largest frame accepted, between 16384 and 16777215.
max_header_list_sizeSome(32_768)Largest header list accepted.
header_table_size4096HPACK dynamic table kept for decoding.
max_continuation_frames100How many CONTINUATION frames one header sequence may span.
max_header_block_bytes65_536Bytes one header block may total before decoding.
rapid_reset_window10_000Window over which client stream resets are counted.
rapid_reset_threshold100Resets within that window that trip a GOAWAY which is what keeps Rapid Reset (CVE-2023-44487) in check.
handshake_timeout10_000How long a connection may sit in the preface and SETTINGS handshake.
drain_timeout4000How long a draining connection waits for its streams after GOAWAY.
recv_window_low_water_mark262_144Once a receive window falls to this it is topped back up.
recv_window_high_water_mark2_097_152What it is topped up to; a wider gap costs fewer WINDOW_UPDATE round trips.
file_read_threshold1_048_576Files at or below this are read into memory, larger ones are streamed from disk.
body_read_timeout10_000How long a single body read waits for the client.

Running Under Supervision

ewe.start runs the server on its own. When it belongs to a supervision tree next to the rest of your program use ewe.supervised instead, which returns a child specification.

supervisor.new(supervisor.OneForAll)
|> supervisor.add(pubsub.worker(pubsub_name))
|> supervisor.add(
  ewe.new(listener_name:, connection_factory_name:, handler:)
  |> ewe.bind(to: "0.0.0.0")
  |> ewe.listening(on: 8080)
  |> ewe.supervised,
)
|> supervisor.start

The line printed on startup comes from ewe.on_start, which receives the scheme and the address the server bound to. Replace it to log it your own way or silence it with ewe.quiet.

Running as an OTP Application

The examples start the server straight from main with a let assert, which is the shortest thing that works while you are trying ewe out. A service is better off letting the OTP application controller own the supervision tree: it starts before anything else runs, it brings the tree down in order on shutdown and it is what a release expects.

Point application_start_module at a module exporting start/2 and stop/1:

[erlang]
application_start_module = "my_app"

start returns the pid of the top supervisor to the application controller, which is the pid it supervises from there on.

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

/// 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) {
  let listener_name = process.new_name("listener_name")
  let connection_factory_name = process.new_name("connection_factory_name")

  case
    supervisor.new(supervisor.OneForOne)
    |> supervisor.add(
      ewe.new(listener_name:, connection_factory_name:, handler: handle_request)
      |> ewe.bind(to: "0.0.0.0")
      |> ewe.listening(on: 8080)
      |> ewe.supervised,
    )
    |> supervisor.start
  {
    Ok(actor.Started(pid:, ..)) -> Ok(pid)
    Error(reason) -> Error(reason)
  }
}

/// The Erlang/OTP application stop callback, called once every process in the
/// tree is down. Any final clean up goes here.
pub fn stop(_state: a) -> atom.Atom {
  atom.create("ok")
}

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

main still has to sleep. gleam run boots the application and then calls it, so without it the node exits as soon as it returns.

Examples

Most sections above link to a runnable example. They live in examples, see its README for how to run them.

API Reference

For detailed API documentation, see hexdocs.pm/ewe.

Search Document