
🐑 ewe
ewe [/juː/] - fluffy HTTP/1 and HTTP/2 web server for Gleam.
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
| 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 | Longer chunk size lines in a chunked body are refused with a 413. |
idle_timeout | 10_000 | How long a connection may stay idle before it is closed. |
body_read_timeout | 10_000 | How long read_body and read_body_chunk wait for more of the body before failing. |
auto_drain_limit | 1_048_576 | The largest unread body discarded so the connection can be reused. A larger body closes the connection. |
auto_drain_chunk_bytes | 65_536 | How many bytes are read at a time while an unread body is discarded. |
| Field | Default | What it does |
|---|---|---|
max_concurrent_streams | Some(100) | How many streams a client may have open at once. |
initial_window_size | 262_144 | How much request body a client may send on a new stream before the server allows more. |
max_frame_size | 16_384 | Largest frame accepted, between 16384 and 16777215. |
max_header_list_size | Some(32_768) | Requests with larger decoded headers are answered with a 431. |
header_table_size | 4096 | Size of the HPACK table used to decode request headers. |
max_continuation_frames | 100 | How many CONTINUATION frames one header block may use. |
max_header_block_bytes | 65_536 | Largest header block across its HEADERS and CONTINUATION frames. |
rapid_reset_window | 10_000 | The time over which rapid_reset_threshold counts resets. |
rapid_reset_threshold | 100 | Most 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_timeout | 10_000 | How long the client has to send its SETTINGS and acknowledge ours. |
idle_timeout | 60_000 | How long a connection may stay idle before it is sent GOAWAY and closed. |
recv_window_low_water_mark | 65_536 | When a client can send only this much more on a stream, the server lets it send more. |
recv_window_high_water_mark | 262_144 | How much request body a stream holds before the handler reads it. |
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 | How much a streamed body, SSE or WebSocket may queue for a slow client before the next write waits. |
file_read_threshold | 1_048_576 | Files up to this size are read into memory, larger ones are sent from disk. |
body_read_timeout | 10_000 | How 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()
}
mainstill has to sleep.gleam runboots 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.