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, 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. 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(Nil) // Reject with 403 } })
use <- mist_transport.upgrade(req, channels, 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.
Seeding initial assigns
Section titled “Seeding initial assigns”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 (here: the user id) Error(_) -> Error(Nil) // 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.
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) _ -> 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”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.
| 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.config(wire.phoenix_codec()), 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.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)| 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