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.
Prerequisites
Section titled “Prerequisites”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(berylandberyl_mist)
1. Write a channel
Section titled “1. Write a channel”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.
import beryl/wireimport beryl/channelimport gleam/jsonimport 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)), ]) })}2. Start beryl and Mist
Section titled “2. Start beryl and Mist”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.
import berylimport beryl/transport/serverimport beryl/wireimport beryl/channelimport beryl_mist as mist_transportimport gleam/bytes_treeimport gleam/erlang/processimport gleam/http/requestimport gleam/http/responseimport gleam/otp/static_supervisorimport mistimport 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"}.
3. Connect from the browser
Section titled “3. Connect from the browser”beryl uses the network message format from Phoenix Channels. You can connect
with the official phoenix npm package or its CDN build.
npm install phoenix<script src="https://cdn.jsdelivr.net/npm/phoenix/priv/static/phoenix.min.js"></script>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); });4. Reject a join
Section titled “4. Reject a join”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.
Use raw dispatch instead
Section titled “Use raw dispatch instead”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.
import beryl/socketimport beryl/wireimport gleam/jsonimport 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.
Next steps
Section titled “Next steps”- Read Choose an API if you are still deciding between the two APIs
- Explore the full examples
- Learn the channel API in depth in the Channels guide
- Learn raw dispatch in the Dispatch guide
- Add Presence tracking to see who's online
- Set up PubSub for distributed messaging
- Configure WebSocket options, including
on_connectauthentication
If a channel does not connect, start with the Troubleshooting guide.
