Skip to content

Quick start

Build a Gleam server and connect it to a browser with the Phoenix JavaScript client. One browser connection is a socket.

The guide starts with beryl's recommended channel API. A channel handler matches subscription names such as room:lobby, decides whether a client can join, and handles messages after the join.

beryl also has a lower-level API called raw dispatch. It sends every event for one socket to an update function that you write. See Choose an API for help selecting between them.

These snippets leave out some files, including the browser HTML. See the examples for complete applications that you can run.

This is a code-first guide. It assumes that you can read Gleam custom types, pattern matching, pipelines, and Result values. It also helps to know how a browser and server exchange WebSocket messages and how an OTP supervisor starts child processes. You do not need prior Phoenix Channels experience.

If beryl's model of state, inputs, and effects is new to you, read beryl's model in one minute. For a complete walkthrough, use the Build a live poll tutorial.

  • Gleam >= 1.18 project targeting Erlang (gleam new my_app)
  • The Git dependencies from the Installation guide added to gleam.toml (beryl and beryl_mist)

A channel handler watches for topic names that match a pattern. In this example, room:* matches room:lobby, room:123, and other names that start with room:. When a client asks to join a matching topic, beryl calls the handler's join function. The function rejects the join or accepts it with state and functions that handle later events.

src/my_app/room_channel.gleam
import beryl/wire
import beryl/channel
import gleam/json
import gleam/result
/// This channel's private state. Each joined topic has one value.
type State {
State(room_id: String, sent: Int)
}
/// Messages that other Gleam processes can send to this channel. This channel
/// does not receive any, so `Nil` is enough.
type Note =
Nil
pub fn channel() -> channel.Handler {
channel.handler("room:*", fn(context: channel.JoinContext(Note)) {
let state = State(room_id: context.topic, sent: 0)
channel.accept(state)
|> channel.on_message(fn(state: State, message: channel.Message) {
case message.event {
"new_msg" ->
channel.next(
State(..state, sent: state.sent + 1),
[
channel.broadcast(
"new_msg",
// `dynamic_to_json` cannot convert deeply nested data.
// Send `null` instead.
result.unwrap(
wire.dynamic_to_json(message.payload),
json.null(),
),
),
channel.reply_ok(
message.reply,
json.object([#("sent", json.int(state.sent + 1))]),
),
],
)
_ ->
channel.next(state, [
channel.reply_error(
message.reply,
json.object([#("error", json.string("unknown_event"))]),
),
])
}
})
|> channel.on_terminate(fn(state: State, _reason) {
[channel.broadcast("left", json.string(state.room_id))]
})
|> channel.with_reply(
json.object([
#("room", json.string(context.topic)),
#("socket_id", json.string(context.socket_id)),
]),
)
// beryl sends these in order after the join response.
|> channel.with_actions([
channel.broadcast("joined", json.string(context.socket_id)),
])
})
}

Give the handlers to beryl. channel.child_spec returns a beryl.Sockets handle for the Mist WebSocket server. It also returns a child specification that tells the OTP supervisor how to start and restart beryl.

src/my_app.gleam
import beryl
import beryl/transport/server
import beryl/wire
import beryl/channel
import beryl_mist as mist_transport
import gleam/bytes_tree
import gleam/erlang/process
import gleam/http/request
import gleam/http/response
import gleam/otp/static_supervisor
import mist
import my_app/room_channel
pub fn handlers() -> List(channel.Handler) {
[room_channel.channel()]
}
pub fn main() -> Nil {
let config =
beryl.config(wire.phoenix_codec())
|> beryl.with_frame_rate(per_second: 35, burst: 70)
|> beryl.with_message_rate(per_second: 30, burst: 60)
let assert Ok(#(sockets, child_specification)) =
channel.child_spec(config, handlers: handlers())
let assert Ok(_root) =
static_supervisor.new(static_supervisor.OneForOne)
|> static_supervisor.add(child_specification)
|> static_supervisor.start()
let assert Ok(_) =
fn(http_request) {
mist_transport.upgrade(
http_request,
sockets,
server.default_config("/socket/websocket"),
fn() { handle_http(http_request) },
)
}
|> mist.new
|> mist.port(8000)
|> mist.start
process.sleep_forever()
}
fn handle_http(
http_request: request.Request(mist.Connection),
) -> response.Response(mist.ResponseData) {
case request.path_segments(http_request) {
[] ->
response.new(200)
|> response.set_body(mist.Bytes(bytes_tree.from_string("Hello!")))
_ ->
response.new(404)
|> response.set_body(mist.Bytes(bytes_tree.new()))
}
}

beryl checks handlers in the order you provide them. The first matching handler receives the topic. Put specific patterns before general patterns. beryl rejects a topic when no handler matches it and returns {"reason": "unmatched topic"}.

beryl uses the network message format from Phoenix Channels. You can connect with the official phoenix npm package or its CDN build.

Terminal window
npm install phoenix
import { Socket } from "phoenix";
// The client adds "/websocket", so this connects to "/socket/websocket".
const socket = new Socket("/socket");
socket.connect();
// Join the "room:lobby" topic.
const channel = socket.channel("room:lobby", { username: "alice" });
channel
.join()
.receive("ok", (resp) => {
// resp is the JSON data you attached with `channel.with_reply`.
console.log("Joined room", resp.room);
})
.receive("error", (resp) => {
console.error("Join failed", resp);
});
// Listen for broadcast messages.
channel.on("new_msg", (payload) => {
console.log("Message:", payload);
});
// Send a message. The channel's `reply_ok` action answers this push.
channel
.push("new_msg", { text: "Hello, world!" })
.receive("ok", (resp) => {
// resp is the data from `channel.reply_ok`: { sent: 1 }
console.log("Delivered", resp);
});

Return channel.reject when a client cannot join a topic. Put join authentication, capacity checks, and message data checks in the join function:

channel.handler("room:*", fn(context) {
case is_room_valid(context.topic) {
False ->
channel.reject(json.object([#("reason", json.string("room_not_found"))]))
True ->
channel.accept(State(room_id: context.topic, sent: 0))
}
})

A rejected join does not create channel state and does not run on_terminate. beryl also rejects topics that have no matching handler.

Raw dispatch sends every event for one socket to your application. You provide one init function, one update function, and the code that decides how to handle each event.

src/my_app/room_app.gleam
import beryl/socket
import beryl/wire
import gleam/json
import gleam/option.{None, Some}
import gleam/result
pub type Message {
NoOp
}
pub type Model {
Model(socket_id: String)
}
pub fn init(info: socket.ConnectInfo(Message)) -> #(Model, List(socket.Effect)) {
#(Model(socket_id: info.socket_id), [])
}
pub fn update(model: Model, input: socket.Input(Message)) -> socket.Next(Model) {
case input {
socket.Join("room:" <> _, payload, ref) ->
socket.Next(
model,
[socket.AcceptJoin(
ref,
Some(result.unwrap(wire.dynamic_to_json(payload), json.null())),
)],
)
// Reject every join that the application does not support.
socket.Join(_topic, _payload, ref) ->
socket.Next(
model,
[socket.RejectJoin(
ref,
json.object([#("reason", json.string("unknown topic"))]),
)],
)
socket.Message(topic, "new_msg", payload, _ref) ->
socket.Next(
model,
[socket.Broadcast(
topic,
"new_msg",
result.unwrap(wire.dynamic_to_json(payload), json.null()),
)],
)
socket.Message(_, _, _, Some(ref)) ->
socket.Next(
model,
[socket.ReplyError(
ref,
json.object([#("error", json.string("unknown_event"))]),
)],
)
socket.Binary(_, _)
| socket.Closed(_, _)
| socket.Info(_)
| socket.Message(_, _, _, None) ->
socket.Next(model, [])
}
}

Start it with beryl.child_spec(config, init: room_app.init, update: room_app.update) instead of channel.child_spec. Add the returned child specification to the same supervisor. Both versions use the same WebSocket setup, client code, and network message format. If update does not accept or reject a Join, beryl rejects it.

The Dispatch guide explains how to handle several groups of topics from one update function.

If a channel does not connect, start with the Troubleshooting guide.