Skip to content

WebSocket Transport

beryl provides a WebSocket transport layer that integrates directly with Mist for handling browser client connections.

The simplest way to add WebSocket support is with mist_transport.upgrade:

import beryl
import beryl/transport/mist as mist_transport
import gleam/bytes_tree
import gleam/http/request
import gleam/http/request.{type Request}
import gleam/http/response
import mist
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.coordinator,
mist_transport.default_config("/socket/websocket"),
)
// Non-WebSocket requests fall through here
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()))
}
}

The upgrade function checks if the request path matches, performs the WebSocket upgrade, and wires the connection to the beryl coordinator.

Use with_on_connect to authenticate connections before upgrading:

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(Nil) // Reject with 403
}
})
use <- mist_transport.upgrade(req, channels.coordinator, config)

Returning Error(Nil) 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.

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.coordinator)
_ -> 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.

beryl uses the Phoenix wire protocol — a JSON array format:

[join_ref, ref, topic, event, payload]
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(
..beryl.default_config(),
heartbeat_interval_ms: 30_000, // Client sends every 30s
heartbeat_timeout_ms: 60_000, // Server evicts after 60s silence
)

Protect against flood attacks with built-in rate limiting:

let config =
beryl.default_config()
|> 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
  • 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