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@9 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 request argument the handler receives contains the connection, which you pass to ewe.read_body to read the body, or to ewe.file and ewe.websocket.

To listen on a Unix domain socket instead of a port, use ewe.unix. ewe.listening_random lets the OS pick a free port and ewe.start returns the address as the actor’s started data. Name the server with ewe.named to look that address later 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)

  let assert Ok(_) =
    ewe.new(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) {
  // Give every body a `content-type`. ewe writes `content-length` and
  // `transfer-encoding` itself, ypu don't need to specify those headers.
  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 checked when the server starts and ewe.start returns an error if they are missing or invalid.

ewe.new(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. Over TLS it is offered through ALPN, and a client that does not choose h2 is served HTTP/1.1. A cleartext connection is served as HTTP/2 when it starts with the HTTP/2 preface.

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 and fails with ewe.BodyTooLarge if it is over limit bytes. Trailer fields sent after the body are added 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"))
  }
}

On HTTP/1, a body the handler did not read is read and discarded after the response, so the connection can be reused. A body over auto_drain_limit causes the connection to be closed instead.

Streaming Bodies

ewe.read_body_chunk reads the body a piece at a time, at most max_chunk_bytes per call, instead of all at once. Each ewe.Chunk comes with the request to pass to the next call.

To send a body in pieces use ewe.stream_response. Its function gets an ewe.ResponseWriter, writes with ewe.send_chunk and must end the response with ewe.finish_chunk or ewe.finish_response. It runs in the process serving the request.

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. Pass the request’s body first since how the file is sent depends on the connection. offset and limit send only part of the file.

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 returns the address the request came from as an ewe.SocketAddress. The address is read once when the client connects.

fn describe_client(connection: ewe.Connection) -> String {
  case ewe.get_client_info(connection) {
    ewe.TcpSocketAddress(ip_address:, port:) -> {
      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)
    }
    ewe.UnixSocketAddress(path: "") -> "unix socket"
    ewe.UnixSocketAddress(path:) -> "unix:" <> path
  }
}

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.Next.

On HTTP/1 the request is the usual Upgrade: websocket handshake and on HTTP/2 it is the extended CONNECT of RFC 8441 which ewe advertises with SETTINGS_ENABLE_CONNECT_PROTOCOL. To keep WebSockets on HTTP/1 only, turn it off:

|> ewe.with_http2(ewe.Http2Options(..ewe.default_http2_options(), websocket: False))
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(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.Next(WebsocketState, Broadcast) {
  case message {
    ewe.TextFrame(text) -> {
      pubsub.publish(state.pubsub, topic: state.topic, message: Text(text))
      ewe.continue(state)
    }

    ewe.BinaryFrame(data) -> {
      pubsub.publish(state.pubsub, topic: state.topic, message: Bytes(data))
      ewe.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.continue(state)
        Error(_send_error) ->
          ewe.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. Like a WebSocket, on_init receives a selector to add whatever the rest of your program sends to this stream, handler is called for each message it picks up 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(_conn, selector) {
    let client = process.new_subject()
    pubsub.subscribe(pubsub, topic:, client:)

    #(client, process.select(selector, client))
  },
  handler: fn(conn, client, message) {
    case ewe.send_event(conn, ewe.event(message)) {
      Ok(Nil) -> ewe.continue(client)
      Error(_send_error) -> ewe.stop()
    }
  },
  on_close: fn(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(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_line128Longer chunk size lines in a chunked body are refused with a 413.
idle_timeout10_000How long a connection may stay idle before it is closed.
body_read_timeout10_000How long read_body and read_body_chunk wait for more of the body before failing.
auto_drain_limit1_048_576The largest unread body discarded so the connection can be reused. A larger body closes the connection.
auto_drain_chunk_bytes65_536How many bytes are read at a time while an unread body is discarded.

ewe.Http2Options:

FieldDefaultWhat it does
max_concurrent_streamsSome(100)How many streams a client may have open at once.
initial_window_size262_144How much request body a client may send on a new stream before the server allows more.
max_frame_size16_384Largest frame accepted, between 16384 and 16777215.
max_header_list_sizeSome(32_768)Requests with larger decoded headers are answered with a 431.
header_table_size4096Size of the HPACK table used to decode request headers.
max_continuation_frames100How many CONTINUATION frames one header block may use.
max_header_block_bytes65_536Largest header block across its HEADERS and CONTINUATION frames.
rapid_reset_window10_000The time over which rapid_reset_threshold counts resets.
rapid_reset_threshold100Most streams reset while their handler is still running, within the window. More resets close the connection. Guards against Rapid Reset (CVE-2023-44487) and MadeYouReset (CVE-2025-8671).
handshake_timeout10_000How long the client has to send its SETTINGS and acknowledge ours.
idle_timeout60_000How long a connection may stay idle before it is sent GOAWAY and closed.
recv_window_low_water_mark65_536When a client can send only this much more on a stream, the server lets it send more.
recv_window_high_water_mark262_144How much request body a stream holds before the handler reads it.
websocketTrueWhether a client may open a WebSocket over HTTP/2 with the extended CONNECT of RFC 8441.
send_buffer_limit1_048_576How much a streamed body, SSE or WebSocket may queue for a slow client before the next write waits.
file_read_threshold1_048_576Files up to this size are read into memory, larger ones are sent from disk.
body_read_timeout10_000How long read_body and read_body_chunk wait for more of the body.

Each read from the socket takes in at most ewe.buffer_size bytes, 64 KiB by default. A larger size means fewer reads for clients that send large bodies.

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(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 top supervisor’s pid, which the application controller then watches.

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) {
  case
    supervisor.new(supervisor.OneForOne)
    |> supervisor.add(
      ewe.new(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.

Graceful Shutdown

This only happens when the server is in the supervision tree of an OTP application as in Running as an OTP Application. A server started from main, even under a supervisor, is killed with the VM on SIGTERM.

When OTP stops the server, each connection gets to finish before it is closed. HTTP/1 connections finish the request they are serving, WebSockets are sent a close frame with code 1001 (going away), SSE streams end, and HTTP/2 connections send GOAWAY and wait for their open streams. ewe.shutdown_timeout sets how long that may take, 15 seconds by default.

ewe.new(handler: handle_request)
|> ewe.shutdown_timeout(30_000)
|> ewe.supervised

Examples

Most sections above link to a runnable example. They live in examples.

API Reference

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

✨ Search Document