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@8 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 read the one it picked from what ewe.start returns. ewe.named gives the server a name that is used by ewe.get_server_info later.

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) {
// 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(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, and it is answered as HTTP/1.1.

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. 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(_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.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(_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(handler: handle_request)
|> ewe.with_http1(http1)
|> ewe.with_http2(http2)
|> ewe.start

ewe.Http1Options:

Field Default What it does
max_request_line 8192 Longer request lines are refused with a 414.
max_header_line 8192 Longer header lines are refused with a 431.
max_headers 100 Requests carrying more header fields are refused with a 431.
max_chunk_size_line 128 Longest chunk size line in a chunked body.
idle_timeout 10_000 How long a connection may sit without sending anything.
body_read_timeout 10_000 How long a single body read waits for the client.
auto_drain_limit 1_048_576 An unread body larger than this closes the connection instead of being drained.
auto_drain_chunk_bytes 65_536 How 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:

Field Default What it does
max_concurrent_streams None How many streams a client may have open at once.
initial_window_size 2_097_152 How much response body a stream may have in flight.
max_frame_size 16_384 Largest frame accepted, between 16384 and 16777215.
max_header_list_size Some(32_768) Largest header list accepted.
header_table_size 4096 HPACK dynamic table kept for decoding.
max_continuation_frames 100 How many CONTINUATION frames one header sequence may span.
max_header_block_bytes 65_536 Bytes one header block may total before decoding.
rapid_reset_window 10_000 Window over which client stream resets are counted.
rapid_reset_threshold 100 Resets within that window that trip a GOAWAY which is what keeps Rapid Reset (CVE-2023-44487) in check.
handshake_timeout 10_000 How long a connection may sit in the preface and SETTINGS handshake.
recv_window_low_water_mark 262_144 Once a receive window falls to this it is topped back up.
recv_window_high_water_mark 2_097_152 What it is topped up to; a wider gap costs fewer WINDOW_UPDATE round trips.
websocket True Whether a client may open a WebSocket over HTTP/2 with the extended CONNECT of RFC 8441.
send_buffer_limit 1_048_576 Bytes a WebSocket stream may already have queued for a client that is not reading before a further write resets it. One message is always sent whatever its size.
file_read_threshold 1_048_576 Files at or below this are read into memory, larger ones are streamed from disk.
body_read_timeout 10_000 How long a single body read waits for the client.

Both protocols read the socket through one buffer, 64 KiB by default. Set it with ewe.buffer_size. A larger one lets one read take in more at once which pays off when clients 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 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) {
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()
}

Note

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

Note

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 going away close frame 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, see its README for how to run them.

API Reference

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