Skip to content

WebSocket Transport

beryl serves channels over WebSockets through a transport package. Two are available and expose the same API:

PackageWeb server
beryl_mistMist
beryl_eweEwe

This guide uses beryl_mist. Every example applies to beryl_ewe with ewe_transport substituted for mist_transport — the config builders, on_connect hook, origin validation, and handler functions are identical.

mist_transport.handler composes the WebSocket upgrade and your regular HTTP handler into a single request handler, so it is the shortest path to a working server:

import beryl
import beryl_mist as mist_transport
import gleam/bytes_tree
import gleam/http/request.{type Request}
import gleam/http/response
import mist
pub fn start(channels: beryl.Channels) {
mist_transport.handler(
channels,
mist_transport.default_config("/socket/websocket"),
handle_http,
)
|> mist.new
|> mist.port(8000)
|> mist.start
}
fn handle_http(
req: Request(mist.Connection),
) -> response.Response(mist.ResponseData) {
case request.path_segments(req) {
[] -> response.new(200) |> response.set_body(mist.Bytes(bytes_tree.new()))
_ -> response.new(404) |> response.set_body(mist.Bytes(bytes_tree.new()))
}
}

WebSocket upgrades on the configured path go to beryl; everything else falls through to handle_http.

When you need the upgrade decision inside your own routing — to run middleware first, or to mount the socket conditionally — use mist_transport.upgrade directly. It matches the request path, performs the upgrade, and calls the continuation when the path does not match:

fn handle_request(
req: Request(mist.Connection),
channels: beryl.Channels,
) -> response.Response(mist.ResponseData) {
// Upgrade /socket/websocket requests to WebSocket
use <- mist_transport.upgrade(
req,
channels,
mist_transport.default_config("/socket/websocket"),
)
// Non-WebSocket requests fall through here
handle_http(req)
}

Use with_on_connect to authenticate connections before upgrading. The hook is beryl's analogue of Phoenix's UserSocket.connect/3: it runs once per socket, before any channel join, and can reject the whole connection.

let config =
mist_transport.default_config("/socket/websocket")
|> mist_transport.with_on_connect(fn(req: Request(mist.Connection)) {
// Check auth token, session, etc.
case validate_token(req) {
Ok(_user) -> Ok(Nil) // Allow connection
Error(_) -> Error(mist_transport.ConnectRejected) // Reject with 403
}
})
use <- mist_transport.upgrade(req, channels, config)

Returning Error(mist_transport.ConnectRejected) sends an HTTP 403 before the WebSocket upgrade. See Connection-level authentication rejection for the client-visible error shape and Authentication failures for diagnosis steps.

Browsers include cookies on WebSocket handshakes. If your socket authentication uses cookies, a malicious site can open a WebSocket to your application from a victim's browser unless you validate the Origin header. This is Cross-Site WebSocket Hijacking (CSWSH).

Use with_allowed_origins to allow only your application origins. Values match the full Origin header exactly: scheme, host, and port when present.

let config =
mist_transport.default_config("/socket/websocket")
|> mist_transport.with_allowed_origins(["https://app.example.com"])
|> mist_transport.with_on_connect(fn(req: Request(mist.Connection)) {
validate_cookie_session(req)
})

Requests with missing or non-matching origins are rejected with HTTP 403 before the WebSocket handshake. If you do not configure an allow-list, existing behavior is unchanged and all origins are accepted.

If you cannot use an origin allow-list, avoid cookie-based WebSocket authentication. Use a token passed explicitly to on_connect and reject invalid tokens before upgrading.

on_connect can also return seeded socket-level assigns instead of Nil. Whatever value you return in Ok(assigns) becomes the socket's initial assigns and is visible to every channel at join time via socket.get_assigns. This lets you authenticate once at connect and avoid repeating per-socket auth in each channel's join:

let config =
mist_transport.default_config("/socket/websocket")
|> mist_transport.with_on_connect(fn(req: Request(mist.Connection)) {
// Validate once, derive socket state, reject on failure.
case validate_token(req) {
Ok(user_id) -> Ok(user_id) // Seed assigns
Error(_) -> Error(mist_transport.ConnectRejected) // Reject with 403
}
})
// The channel reads the connect-seeded assigns at join — no re-auth needed.
fn join(_topic, _payload, socket) {
let user_id = socket.get_assigns(socket)
channel.JoinOk(reply: None, socket: socket)
}

The assigns type returned by on_connect should match the channel's assigns type (commonly a record shared across all topics that require the same auth). When no hook is configured, sockets start with Nil assigns.

If you handle path matching yourself, use upgrade_connection directly:

fn handle_request(req, channels) -> response.Response(mist.ResponseData) {
case request.path_segments(req) {
["ws"] -> mist_transport.upgrade_connection(req, channels)
_ -> response.new(404) |> response.set_body(mist.Bytes(bytes_tree.new()))
}
}

Note: upgrade_connection does not invoke the on_connect callback. Run your own auth check before calling it.

Pass wire.phoenix_codec() to beryl.config to use the Phoenix JSON array format:

[join_ref, ref, topic, event, payload]

Applications can pass a custom codec to beryl.config(codec) to use another text framing or a binary framing. Codec-produced outbound frames are sent as text or binary WebSocket frames according to the codec result.

wire.phoenix_codec() uses beryl's native Phoenix wire implementation, which has no extra dependencies. The public beryl/wire/codec.Codec API and wire format are stable, so applications can supply their own codec to beryl.config for alternative framings.

FieldTypeDescription
join_refstring | nullReference from the join (for reply routing)
refstring | nullUnique message reference (for reply matching)
topicstringTopic name (e.g., "room:lobby")
eventstringEvent name (e.g., "phx_join", "new_message")
payloadanyJSON payload
EventDirectionDescription
phx_joinClient -> ServerJoin a channel
phx_leaveClient -> ServerLeave a channel
heartbeatClient -> ServerKeepalive ping
phx_replyServer -> ClientReply to a client message
phx_errorServer -> ClientError notification
phx_closeServer -> ClientChannel closed

Client sends:

["1", "1", "room:lobby", "phx_join", {"user": "alice"}]

Server replies:

["1", "1", "room:lobby", "phx_reply", {"status": "ok", "response": {}}]
  1. Client connects via WebSocket to the configured path
  2. on_connect callback runs (if configured) — reject returns 403
  3. Transport generates a unique socket ID and registers with the coordinator
  4. Client sends phx_join messages to subscribe to topics
  5. Messages are routed through the coordinator to channel handlers
  6. On disconnect, the coordinator runs terminate on all joined channels

Clients should send periodic heartbeat messages to stay connected:

[null, "ref_123", "phoenix", "heartbeat", {}]

Configure heartbeat timing in the beryl config:

let config =
beryl.config(wire.phoenix_codec())
|> beryl.with_heartbeat(
interval_ms: 30_000, // Client-advisory ping cadence (server does not read it)
timeout_ms: 60_000, // Server evicts after 60s silence (must be >= 2)
)

Protect against flood attacks with built-in rate limiting:

let config =
beryl.config(wire.phoenix_codec())
|> beryl.with_message_rate(per_second: 100, burst: 200)
|> beryl.with_join_rate(per_second: 5, burst: 10)
|> beryl.with_channel_rate(per_second: 50, burst: 100)
LimiterScopeDescription
message_ratePer socketTotal messages per second across all topics
join_ratePer socketJoin attempts per second
channel_ratePer socket+topicMessages per second on a single topic

Cap the number of concurrent connections a single client IP may hold with with_max_connections_per_ip. A value of 0 (the default) means unlimited.

let config =
beryl.config(wire.phoenix_codec())
|> beryl.with_max_connections_per_ip(max_connections: 5)

When a peer is already at its limit, the Mist transport rejects the new upgrade with 429 Too Many Requests before the WebSocket handshake completes. The slot is released automatically when a connection closes, so disconnecting frees capacity for that IP.

The limit is enforced on the real socket peer IP — the address of the TCP connection Mist accepts. beryl deliberately does not trust or parse forwarded headers such as X-Forwarded-For, because any client can set them and would otherwise be able to spoof its address and bypass the limit.

This has an important consequence when beryl runs behind a reverse proxy or load balancer (nginx, HAProxy, a cloud LB, etc.): every connection arrives from the proxy's IP, so a per-IP limit sees all clients as one address and throttles them collectively. In that topology:

  • Enforce per-IP limits at the proxy layer, where the real client IP is known, or
  • Terminate connections directly (no intermediary) if you want beryl's built-in per-IP limit to apply to individual clients.

A built-in trusted-proxy opt-in (to derive the client IP from a forwarded header only when the immediate peer is a configured trusted proxy) may be added in a future release. Until then, treat X-Forwarded-For as untrusted input.

  • Error Handling guide — rejected joins, malformed frames, and client-visible error shapes
  • Supervision guide — supervised startup for production so a coordinator crash doesn't take down the whole transport
  • Troubleshooting — symptom-first diagnosis for connection, join, and message delivery failures