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.
ConnectError
Section titled “ConnectError”pub type ConnectError { ConnectRejected}Errors returned from a transport on_connect callback.
Constructors
Section titled “Constructors”ConnectRejected
Section titled “ConnectRejected”ConnectRejectedReject the WebSocket upgrade with 403 Forbidden.
ConnectionState
Section titled “ConnectionState”pub type ConnectionStateState maintained per WebSocket connection.
ForceCloseError
Section titled “ForceCloseError”pub type ForceCloseError { ForceCloseFailed(reason: String)}An unexpected error while forcibly closing a connection.
FrameDisposition
Section titled “FrameDisposition”pub type FrameDisposition { Continue(ConnectionState) Stop}What a transport must do after it handles an inbound frame.
Constructors
Section titled “Constructors”Continue
Section titled “Continue”Continue(ConnectionState)Keep the connection open with the updated state.
StopClose the connection after a frame limit or transport write failure.
OutboundConfigError
Section titled “OutboundConfigError”pub type OutboundConfigError { InvalidMaxOutboundFrames InvalidMaxOutboundBytes MaxOutboundFramesTooLarge MaxOutboundBytesTooLarge}Errors returned when configuring a connection's outbound budget.
Constructors
Section titled “Constructors”InvalidMaxOutboundFrames
Section titled “InvalidMaxOutboundFrames”InvalidMaxOutboundFramesThe frame limit was less than one.
InvalidMaxOutboundBytes
Section titled “InvalidMaxOutboundBytes”InvalidMaxOutboundBytesThe byte limit was less than one.
MaxOutboundFramesTooLarge
Section titled “MaxOutboundFramesTooLarge”MaxOutboundFramesTooLargeThe frame limit cannot fit in the transport's atomic counter.
MaxOutboundBytesTooLarge
Section titled “MaxOutboundBytesTooLarge”MaxOutboundBytesTooLargeThe byte limit cannot fit in the transport's atomic counter.
SendRequest
Section titled “SendRequest”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.
Constructors
Section titled “Constructors”CloseRuntime-initiated close (e.g. heartbeat eviction).
TransportConfig
Section titled “TransportConfig”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.
Functions
Section titled “Functions”close_connection
Section titled “close_connection”pub fn close_connection(ConnectionState) -> NilClean up a closed connection.
Release the held connection slot and report the disconnect to the runtime.
connect_seed
Section titled “connect_seed”pub fn connect_seed( request.Request(a), List(#(String, String))) -> socket.ConnectSeedBuild 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.
default_config
Section titled “default_config”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.
finish_outbound_write
Section titled “finish_outbound_write”pub fn finish_outbound_write( ConnectionState, Int, Bool) -> FrameDispositionComplete 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.
handle_binary_frame
Section titled “handle_binary_frame”pub fn handle_binary_frame( ConnectionState, BitArray) -> FrameDispositionCheck 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.
handle_text_frame
Section titled “handle_text_frame”pub fn handle_text_frame( ConnectionState, String) -> FrameDispositionSize-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.
handler
Section titled “handler”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.
init_connection
Section titled “init_connection”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.
is_websocket_request
Section titled “is_websocket_request”pub fn is_websocket_request(request.Request(a)) -> BoolDetermine 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.
upgrade
Section titled “upgrade”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:
- Applies the configured origin policy and the
?vsnversion check, rejecting failures withreject(403). - Acquires a connection slot for
request_ip(request)(per-IP and node-wide ceilings), rejecting withreject(429)when at a limit. - Runs any
on_connectcallback; onError(ConnectRejected)the slot is released and the request is rejected withreject(403). - Hands admitted requests to
acceptwith the callback's connect metadata (empty when no callback is configured) and the held permit.
Non-matching paths fall through to next.
Path matching
Section titled “Path matching”Request and configured paths are normalized without trailing or doubled slashes before an exact comparison.
Connection limits
Section titled “Connection limits”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.
with_allow_all_origins
Section titled “with_allow_all_origins”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.
with_allowed_origins
Section titled “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).
with_on_connect
Section titled “with_on_connect”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.
with_outbound_limits
Section titled “with_outbound_limits”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.
