
🐑 ewe
ewe [/juː/] - fluffy package for building web servers.
Installation
gleam add ewe@3 gleam_erlang gleam_otp gleam_http logging
Getting Started
import gleam/erlang/process
import logging
import gleam/http/response
import ewe.{type Request, type Response}
pub fn main() {
logging.configure()
logging.set_level(logging.Info)
let assert Ok(_) =
ewe.new(handler)
|> ewe.bind("0.0.0.0")
|> ewe.listening(port: 8080)
|> ewe.start
process.sleep_forever()
}
fn handler(_req: Request) -> Response {
response.new(200)
|> response.set_header("content-type", "text/plain; charset=utf-8")
|> response.set_body(ewe.TextData("Hello, World!"))
}
Usage
HTTPS
To enable HTTPS support via TLS, use ewe.enable_tls with paths to your certificate and key files. The server validates the certificate and key files on startup and will crash if they’re missing or invalid.
ewe.new(handler)
|> ewe.bind("0.0.0.0")
|> ewe.listening(port: 8080)
|> ewe.enable_tls(
certificate_file: "priv/localhost.crt",
key_file: "priv/localhost.key",
)
|> ewe.start
Sending Response
ewe provides several response body types (see ewe.ResponseBody type). Request handler must return response.Response type with ewe.ResponseBody. You can also use ewe.Request/ewe.Response as they are aliases for request.Request(Connection)(see request.Request & ewe.Connection)/response.Response(ResponseBody).
import gleam/crypto
import gleam/http/request.{type Request}
import gleam/http/response.{type Response}
import gleam/int
import gleam/result
import ewe.{type Connection, type ResponseBody}
fn handler(req: Request(Connection)) -> Response(ResponseBody) {
case request.path_segments(req) {
["hello", name] -> {
// Use TextData for text responses.
//
response.new(200)
|> response.set_header("content-type", "text/plain; charset=utf-8")
|> response.set_body(ewe.TextData("Hello, " <> name <> "!"))
}
["bytes", amount] -> {
// Use BitsData for binary responses.
//
let random_bytes =
int.parse(amount)
|> result.unwrap(0)
|> crypto.strong_random_bytes()
response.new(200)
|> response.set_header("content-type", "application/octet-stream")
|> response.set_body(ewe.BitsData(random_bytes))
}
_ ->
// Use Empty for responses with no body (like 404, 204, etc).
//
response.new(404)
|> response.set_body(ewe.Empty)
}
}
Reading Body
To read the body of a request, use ewe.read_body. This function is intended for cases where the entire body can safely be loaded into memory.
import gleam/http/request
import gleam/http/response
import gleam/result
import ewe.{type Request, type Response}
fn handler(req: Request) -> Response {
let content_type =
request.get_header(req, "content-type")
|> result.unwrap("application/octet-stream")
// Read the entire request body into memory with a 10KB limit. This blocks
// until the full body is received.
//
case ewe.read_body(req, 10_240) {
Ok(req) ->
response.new(200)
|> response.set_header("content-type", content_type)
|> response.set_body(ewe.BitsData(req.body))
Error(ewe.BodyTooLarge) ->
response.new(413)
|> response.set_header("content-type", "text/plain; charset=utf-8")
|> response.set_body(ewe.TextData("Body too large"))
Error(ewe.InvalidBody) ->
response.new(400)
|> response.set_header("content-type", "text/plain; charset=utf-8")
|> response.set_body(ewe.TextData("Invalid request"))
}
}
Streaming Body
For larger request bodies, ewe.stream_body provides a streaming interface. It produces a ewe.Consumer which can be called repeatedly to read fixed-size chunks. This enables efficient handling of large payloads without buffering them fully.
As for responses, use ewe.chunked_body to send a chunked response for streaming data to the client. The response body is managed through ewe.ChunkedBody and chunks are sent by calling ewe.send_chunk. Handlers control the connection lifecycle with ewe.ChunkedNext.
pub type Message {
Chunk(BitArray)
Done
BodyError(ewe.BodyError)
}
// Recursively consume chunks from the request body and send them to the
// chunked response handler via the subject.
//
fn stream_resource(
consumer: ewe.Consumer,
subject: Subject(Message),
chunk_size: Int,
) -> Nil {
process.sleep(int.random(250))
// Call the consumer with the chunk size. It returns the next chunk of data
// and a new consumer for the remaining body.
//
case consumer(chunk_size) {
Ok(ewe.Consumed(data, next)) -> {
logging.log(logging.Info, {
"Consumed "
<> int.to_string(bit_array.byte_size(data))
<> " bytes: "
<> string.inspect(data)
})
process.send(subject, Chunk(data))
// Recursively process the next chunk.
//
stream_resource(next, subject, chunk_size)
}
Ok(ewe.Done) -> process.send(subject, Done)
Error(body_error) -> process.send(subject, BodyError(body_error))
}
}
fn handle_stream(req: Request, chunk_size: Int) -> Response {
let content_type =
request.get_header(req, "content-type")
|> result.unwrap("application/octet-stream")
// Get a consumer function for streaming the request body.
//
case ewe.stream_body(req) {
Ok(consumer) -> {
// Set up a chunked response. The response is sent in chunks as we
// consume the request body.
//
ewe.chunked_body(
req,
response.new(200) |> response.set_header("content-type", content_type),
// Spawn a separate process to consume the body and send chunks.
// This prevents blocking the handler while reading data.
//
on_init: fn(subject) {
let _pid =
fn() { stream_resource(consumer, subject, chunk_size) }
|> process.spawn
},
handler: fn(chunked_body, state, message) {
case message {
Chunk(data) ->
case ewe.send_chunk(chunked_body, data) {
Ok(Nil) -> ewe.chunked_continue(state)
Error(_) -> ewe.chunked_stop_abnormal("Failed to send chunk")
}
Done -> ewe.chunked_stop()
BodyError(body_error) ->
ewe.chunked_stop_abnormal(string.inspect(body_error))
}
},
on_close: fn(_conn, _state) {
logging.log(logging.Info, "Stream closed")
},
)
}
Error(_) ->
response.new(400)
|> response.set_header("content-type", "text/plain; charset=utf-8")
|> response.set_body(ewe.TextData("Invalid request"))
}
}
Serving Files
Static files can be sent using ewe.file. It accepts a path and optional offset/limit parameters. This allows serving HTML pages, assets, or binary files with minimal effort.
import gleam/http/response
import gleam/option.{None}
import ewe.{type Response}
fn serve_file(path: String) -> Response {
// Load file from disk using ewe.file(). This efficiently streams the file
// content without loading it entirely into memory.
//
// In production, make sure to validate paths to prevent directory traversal
// attacks! (e.g., requests to "../../../etc/passwd")
//
case ewe.file("public" <> path, offset: None, limit: None) {
Ok(file) -> {
response.new(200)
|> response.set_header("content-type", "application/octet-stream")
|> response.set_body(file)
}
Error(_) -> {
response.new(404)
|> response.set_header("content-type", "text/plain; charset=utf-8")
|> response.set_body(ewe.TextData("File not found"))
}
}
}
WebSocket
Use ewe.upgrade_websocket to switch an HTTP request into a WebSocket connection. Incoming messages are represented as ewe.WebsocketMessage. Outgoing frames are sent with ewe.send_text_frame or ewe.send_binary_frame. Handlers control the connection lifecycle with ewe.WebsocketNext.
import gleam/erlang/charlist.{type Charlist}
import gleam/erlang/process.{type Pid, type Subject}
import gleam/http/request
import gleam/http/response
import logging
import ewe.{type Request, type Response}
type PubSubMessage {
Subscribe(topic: String, client: Subject(Broadcast))
Publish(topic: String, message: Broadcast)
Unsubscribe(topic: String, client: Subject(Broadcast))
}
type Broadcast {
Text(String)
Bytes(BitArray)
}
type WebsocketState {
WebsocketState(
pubsub: Subject(PubSubMessage),
topic: String,
client: Subject(Broadcast),
)
}
fn handler(req: Request, pubsub: Subject(PubSubMessage)) -> Response {
case request.path_segments(req) {
["topic", topic] -> handle_topic(req, pubsub, topic)
_ ->
response.new(404)
|> response.set_body(ewe.Empty)
}
}
fn handle_topic(req: Request, pubsub: Subject(PubSubMessage), topic: String) {
// Upgrade the HTTP connection to WebSocket. Unlike SSE, WebSocket is
// bidirectional - both client and server can send messages at any time.
//
ewe.upgrade_websocket(
req,
// Initialize the WebSocket connection. The selector allows receiving
// messages from both the WebSocket and the pubsub system.
//
on_init: fn(_conn, selector) {
let client = process.new_subject()
process.send(pubsub, Subscribe(topic:, client:))
let state = WebsocketState(pubsub:, topic:, client:)
// Add the client subject to the selector to receive broadcast messages.
//
let selector = process.select(selector, client)
#(state, selector)
},
handler: handle_websocket_message,
on_close: fn(_conn, state) {
process.send(pubsub, Unsubscribe(state.topic, state.client))
},
)
}
// Handle three types of messages: text from client, binary from client,
// and broadcast messages from the pubsub system.
//
fn handle_websocket_message(
conn: ewe.WebsocketConnection,
state: WebsocketState,
msg: ewe.WebsocketMessage(Broadcast),
) -> ewe.WebsocketNext(WebsocketState, Broadcast) {
case msg {
// Text message from the client - broadcast to all subscribers.
//
ewe.Text(text) -> {
process.send(state.pubsub, Publish(state.topic, Text(text)))
ewe.websocket_continue(state)
}
// Binary message from the client - broadcast to all subscribers.
//
ewe.Binary(binary) -> {
process.send(state.pubsub, Publish(state.topic, Bytes(binary)))
ewe.websocket_continue(state)
}
// User message from the pubsub - forward to this client.
//
ewe.User(message) -> {
let assert Ok(_) = case message {
Text(text) -> ewe.send_text_frame(conn, text)
Bytes(binary) -> ewe.send_binary_frame(conn, binary)
}
ewe.websocket_continue(state)
}
}
}
Server-Sent Events
Use ewe.sse to establish a Server-Sent Events connection for real-time data streaming to clients. The connection is managed through ewe.SSEConnection and events are sent with ewe.send_event. Handlers control the connection lifecycle with ewe.SSENext. This enables efficient one-way communication for live updates, notifications, or real-time data feeds.
import gleam/bit_array
import gleam/erlang/process.{type Subject}
import gleam/http
import gleam/http/response
import ewe
type PubSubMessage {
Subscribe(client: Subject(String))
Unsubscribe(client: Subject(String))
Publish(String)
}
fn handler(req: ewe.Request, pubsub: Subject(PubSubMessage)) -> ewe.Response {
case req.method, req.path {
http.Get, "/sse" ->
// Establish a Server-Sent Events connection. SSE is a one-way channel
// from server to client. The connection stays open and the server can
// push events at any time.
//
ewe.sse(
req,
// Initialize the connection and subscribe this client to the pubsub.
//
on_init: fn(client) {
process.send(pubsub, Subscribe(client))
client
},
// Handle messages from the pubsub and send them as SSE events.
//
handler: fn(conn, client, message) {
case ewe.send_event(conn, ewe.event(message)) {
Ok(Nil) -> ewe.sse_continue(client)
Error(_) -> ewe.sse_stop()
}
},
// Clean up when the client disconnects.
//
on_close: fn(_conn, client) {
process.send(pubsub, Unsubscribe(client))
},
)
// Accept messages via POST and broadcast them to all SSE clients.
//
http.Post, "/post" -> {
case ewe.read_body(req, 128) {
Ok(req) -> {
case bit_array.to_string(req.body) {
Ok(message) -> {
process.send(pubsub, Publish(message))
response.new(200) |> response.set_body(ewe.Empty)
}
Error(Nil) -> response.new(400) |> response.set_body(ewe.Empty)
}
}
Error(_) -> response.new(400) |> response.set_body(ewe.Empty)
}
}
_, _ -> response.new(404) |> response.set_body(ewe.Empty)
}
}
API Reference
For detailed API documentation, see hexdocs.pm/ewe.