Skip to content

beryl/transport/server

Server-agnostic WebSocket transport infrastructure.

This module carries everything a WebSocket transport package needs that does not depend on a particular web server: transport configuration and its builders, the upgrade admission pipeline (path matching, origin policy, ?vsn negotiation, connection limits, on_connect authentication), per-connection lifecycle choreography, and the inbound frame pipeline (size caps, frame-rate limiting, decoding, routing), plus bounded outbound admission and write accounting.

Transport packages such as beryl_mist and beryl_ewe supply only the server-specific glue: the WebSocket upgrade call, frame sending, and peer IP extraction. All functions here are generic over the gleam/http request body type, so one config value works with any transport whose server exposes gleam/http requests.

pub type ConnectError {
ConnectRejected
}

Errors returned from a transport on_connect callback.

ConnectRejected

Reject the WebSocket upgrade with 403 Forbidden.

pub type ConnectionState

State maintained per WebSocket connection.

pub type ForceCloseError {
ForceCloseFailed(reason: String)
}

An unexpected error while forcibly closing a connection.

pub type FrameDisposition {
Continue(ConnectionState)
Stop
}

What a transport must do after it handles an inbound frame.

Continue(ConnectionState)

Keep the connection open with the updated state.

Stop

Close the connection after a frame limit or transport write failure.

pub type OutboundConfigError {
InvalidMaxOutboundFrames
InvalidMaxOutboundBytes
MaxOutboundFramesTooLarge
MaxOutboundBytesTooLarge
}

Errors returned when configuring a connection's outbound budget.

InvalidMaxOutboundFrames

The frame limit was less than one.

InvalidMaxOutboundBytes

The byte limit was less than one.

MaxOutboundFramesTooLarge

The frame limit cannot fit in the transport's atomic counter.

MaxOutboundBytesTooLarge

The byte limit cannot fit in the transport's atomic counter.

pub type SendRequest {
SendText(
String,
bytes: Int
)
SendBinary(
BitArray,
bytes: Int
)
Close
}

Outbound requests from the runtime to a connection process.

Transports receive these as custom or user WebSocket messages. They send the frame or close the connection.

Close

Runtime-initiated close (e.g. heartbeat eviction).

pub type TransportConfig(a)

Configuration for a WebSocket transport.

The body parameter is the server's request body type. The same configuration works with any transport built on gleam/http requests.

pub fn close_connection(ConnectionState) -> Nil

Clean up a closed connection.

Release the held connection slot and report the disconnect to the runtime.

pub fn connect_seed(
request.Request(a),
List(#(String, String))
) -> socket.ConnectSeed

Build the connection seed for an app-dispatch system's init function.

The function receives the seed as ConnectInfo.seed. Systems that do not use connect metadata can ignore it.

metadata is the ordered list of string pairs from the configured on_connect callback. It is empty when no callback is configured or the callback returns no metadata. This function preserves order and duplicate keys.

pub fn default_config(String) -> TransportConfig(a)

Create a default transport config with no connect hook.

The resulting configuration sets ConnectSeed.metadata to [] and applies the origin.SameOrigin origin policy, which rejects cross-site WebSocket upgrades before the handshake as CSWSH protection. Same-origin upgrades and non-browser clients (no Origin header) are admitted without configuration. Each connection also has an outbound budget of 256 frames and 1 MiB of payload data. A connection that exceeds either limit is closed.

Add with_on_connect to authenticate connections and/or seed connect metadata. Use with_allowed_origins to set an explicit allow-list. Use with_allow_all_origins to opt out of origin checking entirely.

pub fn finish_outbound_write(
ConnectionState,
Int,
Bool
) -> FrameDisposition

Complete one outbound write.

On success, this releases the frame and byte reservation and keeps the connection open. On error, it releases the reservation, records the connection as closed, logs the failure, and tells the transport to stop.

pub fn handle_binary_frame(
ConnectionState,
BitArray
) -> FrameDisposition

Check the size and rate of an inbound binary frame, then decode it in the connection process. A codec without a binary decoder keeps the raw transport.route_binary fan-out through the runtime.

pub fn handle_text_frame(
ConnectionState,
String
) -> FrameDisposition

Size-check, rate-check, and decode an inbound text frame in the connection process, so parse cost stays there and only valid, rate-admitted messages reach the runtime.

Oversized frames return Stop and close the connection. The function silently drops over-rate frames. It logs and drops frames that it cannot decode.

pub fn handler(
upgrade: fn(request.Request(a), fn() -> response.Response(b)) -> response.Response(b),
http_fallback: fn(request.Request(a)) -> response.Response(b)
) -> fn(request.Request(a)) -> response.Response(b)

Build a request handler for WebSocket upgrades and other HTTP requests.

The handler sends upgrade requests to the transport-specific upgrade function. It sends all other requests to HTTP.

pub fn init_connection(
sockets: beryl.Sockets,
seed: socket.ConnectSeed,
connection_permit: transport.ConnectionPermit,
base_selector: process.Selector(SendRequest),
config: TransportConfig(a),
force_close: fn() -> Result(Nil, ForceCloseError),
logger_name: String,
telemetry: transport.Telemetry,
codec: option.Option(codec.Codec)
) -> #(ConnectionState, process.Selector(SendRequest))

Initialize a new WebSocket connection in its connection process.

This function binds the held connection slot to the calling process. The limiter reclaims the slot if the process dies without a clean close. The function then monitors the owning runtime and atomically registers the socket and its runtime-triggered closer with that owner. A concurrent restart cannot redirect admission to the next runtime. The connection closes if no runtime is available or the captured owner changed.

Returns the connection state and a selector (extending base_selector) that delivers SendRequest values from the runtime; the transport must select on it and act on each request. If the request owner died before the transfer, the reservation bind fails, runtime admission is skipped, and the selector immediately delivers Close. Call close_connection when the connection closes. logger_name names the transport in decode warnings (e.g. "beryl_mist"). codec is the codec negotiated for this socket; None inherits the app-wide codec.

force_close must immediately close the underlying socket. beryl calls it if binding fails or the outbound budget is full, so a blocked writer cannot retain an unbounded mailbox. Return an error when the close fails unexpectedly; beryl logs that failure.

pub fn is_websocket_request(request.Request(a)) -> Bool

Determine whether a request is a WebSocket upgrade request.

This function checks for the standard Upgrade: websocket header without regard to case. Use it to distinguish WebSocket handshakes from regular HTTP traffic on the same listener.

pub fn upgrade(
request: request.Request(a),
sockets: beryl.Sockets,
config: TransportConfig(a),
telemetry: transport.Telemetry,
request_ip: fn(request.Request(a)) -> Result(String, Nil),
reject: fn(Int) -> response.Response(b),
accept: fn(List(#(String, String)), transport.ConnectionPermit) -> response.Response(b),
next: fn() -> response.Response(b)
) -> response.Response(b)

Run the shared upgrade admission pipeline for a request.

When the request path matches config.path, the pipeline:

  1. Applies the configured origin policy and the ?vsn version check, rejecting failures with reject(403).
  2. Acquires a connection slot for request_ip(request) (per-IP and node-wide ceilings), rejecting with reject(429) when at a limit.
  3. Runs any on_connect callback; on Error(ConnectRejected) the slot is released and the request is rejected with reject(403).
  4. Hands admitted requests to accept with the callback's connect metadata (empty when no callback is configured) and the held permit.

Non-matching paths fall through to next.

Request and configured paths are normalized without trailing or doubled slashes before an exact comparison.

When beryl.with_max_connections_per_ip is configured, the limit is enforced before completing the handshake, returning reject(429) once the peer is at its limit. request_ip must return the real socket peer IP from the TCP connection, or Error(Nil) when unavailable. Unknown peers share one limiter bucket rather than bypassing the limit. Forwarded headers such as X-Forwarded-For must not be trusted or parsed, because clients can set them and would otherwise spoof their address to bypass the limit. Behind a trusted reverse proxy, all connections share the proxy's IP. Resolve the real client IP at the proxy layer. See the WebSocket transport guide.

beryl.with_connection_rate_per_ip independently caps connection attempts from each peer IP and also returns reject(429) when its token bucket is exhausted. Its state survives disconnects and app runtime restarts, so reconnecting does not refresh the configured burst.

When beryl.with_max_connections is configured, a node-wide ceiling on concurrent connections across all IPs is likewise enforced with reject(429) before allocating any long-lived socket/runtime state. The two limits compose: a connection must be under both to be admitted. The node-wide ceiling bounds total resource use when a per-IP limit alone cannot (many distributed source addresses / IPv6 rotation). It is enforced per BEAM node, so across a load-balanced cluster the effective ceiling scales with the node count. Use the load balancer's controls for a cluster-wide cap.

pub fn with_allow_all_origins(TransportConfig(a)) -> TransportConfig(a)

Disable Origin checking, allowing WebSocket upgrades from any origin.

This disables the default origin.SameOrigin CSWSH protection. Use it only for sockets that do not rely on ambient browser credentials (cookies, sessions) for authorization, or that authenticate every message independently. For cookie/session-authenticated apps, prefer the default SameOrigin policy or with_allowed_origins.

pub fn with_allowed_origins(
TransportConfig(a),
List(String)
) -> TransportConfig(a)

Restrict WebSocket upgrades to requests whose Origin header exactly matches one of the given values.

This replaces the default origin.SameOrigin policy with an origin.AllowList. Values are matched exactly against the full Origin header, including scheme and host (and port when present), such as "https://app.example.com". Missing or non-matching origins are rejected with 403 Forbidden before the WebSocket handshake.

Prefer this over with_allow_all_origins when you know the exact origins that should be allowed (e.g. behind a reverse proxy that rewrites the Host header, where SameOrigin cannot see the public host).

pub fn with_on_connect(
TransportConfig(a),
fn(request.Request(a)) -> Result(List(#(String, String)), ConnectError)
) -> TransportConfig(a)

Set a socket-level connect/authentication callback on the transport config.

The callback receives the HTTP request before the WebSocket upgrade. It runs once per socket. Return Ok(metadata) to allow the connection and set ConnectSeed.metadata. The metadata is an ordered list of string pairs delivered to the app's init through ConnectInfo.seed. Return Error(ConnectRejected) to reject the connection with a 403 Forbidden response before any topic join.

ConnectSeed.metadata preserves callback order and duplicate keys. Transports never log metadata values.

pub fn with_outbound_limits(
TransportConfig(a),
max_frames: Int,
max_bytes: Int
) -> Result(TransportConfig(a), OutboundConfigError)

Set the per-connection outbound frame and payload-byte limits.

max_frames must be from 1 through 8,388,607. max_bytes must be from 1 through 1,099,511,627,775 (one byte less than 1 TiB). Before beryl enqueues a text or binary frame, it reserves one frame and the payload's byte size. If either limit would be exceeded, the frame is rejected and the slow connection is closed. Capacity is released after a successful write, a write error, or connection close.