WebSocket Transport
beryl provides a WebSocket transport layer that integrates directly with Mist for handling browser client connections.
Basic setup
Section titled “Basic setup”The simplest way to add WebSocket support is with mist_transport.upgrade:
import berylimport beryl/transport/mist as mist_transportimport gleam/bytes_treeimport gleam/http/requestimport gleam/http/request.{type Request}import gleam/http/responseimport 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.
Authentication
Section titled “Authentication”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.
Direct upgrade
Section titled “Direct upgrade”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.
Wire protocol
Section titled “Wire protocol”beryl uses the Phoenix wire protocol — a JSON array format:
[join_ref, ref, topic, event, payload]| Field | Type | Description |
|---|---|---|
join_ref | string | null | Reference from the join (for reply routing) |
ref | string | null | Unique message reference (for reply matching) |
topic | string | Topic name (e.g., "room:lobby") |
event | string | Event name (e.g., "phx_join", "new_message") |
payload | any | JSON payload |
System events
Section titled “System events”| Event | Direction | Description |
|---|---|---|
phx_join | Client -> Server | Join a channel |
phx_leave | Client -> Server | Leave a channel |
heartbeat | Client -> Server | Keepalive ping |
phx_reply | Server -> Client | Reply to a client message |
phx_error | Server -> Client | Error notification |
phx_close | Server -> Client | Channel closed |
Example: join flow
Section titled “Example: join flow”Client sends:
["1", "1", "room:lobby", "phx_join", {"user": "alice"}]Server replies:
["1", "1", "room:lobby", "phx_reply", {"status": "ok", "response": {}}]Connection lifecycle
Section titled “Connection lifecycle”- Client connects via WebSocket to the configured path
on_connectcallback runs (if configured) — reject returns 403- Transport generates a unique socket ID and registers with the coordinator
- Client sends
phx_joinmessages to subscribe to topics - Messages are routed through the coordinator to channel handlers
- On disconnect, the coordinator runs
terminateon all joined channels
Heartbeats
Section titled “Heartbeats”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)Rate limiting
Section titled “Rate limiting”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)| Limiter | Scope | Description |
|---|---|---|
message_rate | Per socket | Total messages per second across all topics |
join_rate | Per socket | Join attempts per second |
channel_rate | Per socket+topic | Messages per second on a single topic |
Next steps
Section titled “Next steps”- 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