Move from raw dispatch to channel handlers
In Elm and Lustre, a parent component owns its children. The parent model stores each child model. The parent message type has one variant for each child message type. The parent update function looks at the message and sends it to the correct child.
Raw beryl works the same way. One update function sees every topic on a socket.
This is type-safe, and it gives you full control. But the work grows with each
new topic family you add. beryl/channel does that routing for you. It runs on
the same core runtime.
Raw dispatch makes your update function route topics
Section titled “Raw dispatch makes your update function route topics”Imagine one socket that serves polls, document cursors, and account alerts. In
raw dispatch, your Model must hold state for all three. Your Message type
must include every server-side message for all three. Your update function must
then route each input:
Joinby topic pattern;MessageandBinaryby topic and event name;Closedto the correct cleanup code;Infoby your own message type.
This is the same job a Lustre parent does. Here, your code does the mapping instead of a DOM event. This works well when one topic family does most of the work. It also works well when one effect list must coordinate several topics.
The live-poll example already shows the start of this cost:
pub type Stage { ReadOnly Voting Timed}
pub type Message { ClosePoll(topic: String)}
pub type Model { Model(sender: socket.Sender(Message), topics: Set(String))}This excerpt comes from
raw.gleam.
Add a second, unrelated guide topic and three things grow. Message gets
more variants. Model gets more fields. The update function gets more
branches.
This is the price you pay when your app owns the full socket router.
Leave the poll, then send a guide tip. The same connection still serves guide.
Joined. Room state: "demo".
Reply to get_state: Gleam 0 / Erlang 0
Joined. Deliveries: 1.
Initial tip: A second handler owns this private message type.
Which BEAM language are you using today?
poll:demo: open. Room members: 1.
Gleam 0 / Erlang 0
Guide is not a poll member. Neither channel state nor the socket model holds these totals.
Application-owned routing and private state (pseudocode)
Model(sender, topics: {"poll:demo", "guide"}, guide_deliveries: 1)
Message = ClosePollTopic(topic) | GuideReady(generation, text)Join poll and guide; route GuideReady through Info.
Last output (pseudocode)
Push("guide", "tip", {delivery: 1, text})One socket joined two topics, fetched its poll, and received the initial guide tip.
Local simulation of the checked teaching fixture. Send guide tip represents another typed server delivery, not a control in the runnable example. The routing comparison omits automatic poll closing. Both APIs keep application types.
Vote, send a guide tip, then leave the poll and send another tip. The connection and guide subscription remain active. Switch the explanatory view at any point: the poll and guide counts do not change, and no join or message runs because you changed views.
The demo extends the poll example with a guide topic. With raw dispatch,
you add its state and messages to the socket-wide Model and Message types.
With channel handlers, you keep each handler's state and message type separate.
Compare both implementations in
tutorial_composition.gleam.
This demo has one poll member, so leaving the poll removes its room. Rejoining starts at zero.
Channel handlers route topics for you
Section titled “Channel handlers route topics for you”For apps with many topic families, or apps shaped like Phoenix Channels, use
beryl/channel. Step 4 of the example starts it like this:
let assert Ok(#(sockets, child_specification)) = channel.child_spec( beryl.config(wire.phoenix_codec()), handlers: channel_handler.handlers(polls, clock, 60_000), )This excerpt comes from
step_04.gleam.
channel.child_spec takes the same beryl.Config as beryl.child_spec. It
returns the same two values: a beryl.Sockets handle and a child specification
for your supervisor.
You give it a list of handlers. The example has two:
pub fn handlers( polls: store.Store, clock: timer.Timer, duration_ms: Int,) -> List(channel.Handler) { [poll_channel(polls, clock, duration_ms), guide_channel()]}One handler owns the poll:* topics. The other owns guide. Each one uses a
different state type and a different info type. Both still fit in one
List(channel.Handler). The next sections show how.
A handler creates one channel for each join
Section titled “A handler creates one channel for each join”A handler has two parts: a topic pattern and a join function. When a client
joins a topic that matches the pattern, beryl calls the join function with a
channel.JoinContext. The join function returns the new channel.
pub type PollInfo { ClosePoll}
fn poll_channel( polls: store.Store, clock: timer.Timer, duration_ms: Int,) -> channel.Handler { channel.handler("poll:*", fn(context) { let room = case context.parameters { [room] -> room _ -> "" } store.join(polls, room) timer.after(clock, duration_ms, fn() { channel.notify(context.self, ClosePoll) })
channel.accept(room) |> channel.on_message(fn(room, message) { handle_message(polls, room, message) }) |> channel.on_info(fn(room, message) { let ClosePoll = message case store.close(polls, room) { store.ClosedNow(state) -> channel.next(room, [ channel.broadcast("poll_closed", poll.to_json(state)), ]) store.AlreadyClosed(_) | store.RoomNotFound -> channel.stay(room) } }) |> channel.on_terminate(fn(room, _reason) { store.leave(polls, room) [] }) })}This exact excerpt comes from
channel_handler.gleam.
Read it from the top:
channel.handlerpairs the patternpoll:*with the join function.context.parametersholds the part of the topic that matched*. Forpoll:demo, that is["demo"].context.selfis a typed sender for this channel. The timer uses it to sendClosePolllater.channel.accept(room)accepts the join. The argument is the channel's starting state. Here, the state is the room name.- Each
on_*call adds a callback. A callback receives the current state and returns the next state plus a list of actions. on_terminateruns when the channel closes. Here, it tells the store that this socket has left the room.
The state type is String. The info type is PollInfo. Neither type appears
in a socket-wide Model or Message. They belong to this handler only.
Keep different state types in one handler list
Section titled “Keep different state types in one handler list”channel.Handler has no type parameters. This is what lets the poll handler
and the guide handler share one list.
Here is how it works. channel.handler stores your join function inside a
closure. When the join function runs, channel.accept and each on_* call
store the state and callbacks inside more closures. Each channel.next does
the same with the new state. The types stay concrete inside those closures.
From the outside, every handler has the same type.
In the experiment above, compare the poll instance's String state and
PollInfo with guide's Int state and GuideInfo. A guide tip changes only
guide's count. Poll totals belong to the shared store, not either instance.
beryl does not turn your state or info messages into Dynamic. Only the
client payload is Dynamic, because it comes from the wire. That is true for
both APIs.
The channel layer is built on the public core API. It gives beryl.child_spec
its own raw init and update pair. It does not go around the runtime.
Add a handler with different types
Section titled “Add a handler with different types”The guide handler uses different types from the poll handler:
type GuideInfo { Ready(String)}
fn guide_channel() -> channel.Handler { channel.handler("guide", fn(context) { timer_message(context.self) channel.accept(0) |> channel.on_info(fn(count, message) { let Ready(text) = message channel.next(count + 1, [ channel.push( "tip", json.object([ #("text", json.string(text)), #("delivery", json.int(count + 1)), ]), ), ]) }) })}This exact excerpt also comes from channel_handler.gleam. The guide channel
uses an Int for state and GuideInfo for info. The poll channel uses a
String and PollInfo. You do not write Model(PollState, GuideState) or
Message(PollMessage | GuideMessage). You put the two handlers in a list.
The browser joins guide after it joins its poll topic. The Ready message
becomes a tip push. The client stores the tip as the title of its status
element.
Actions apply to one channel
Section titled “Actions apply to one channel”A raw effect names its topic:
socket.BroadcastFrom(topic, "poll_state", poll.to_json(state))A channel action does not:
channel.broadcast_from("poll_state", poll.to_json(state))The channel already knows its topic. Your callback code is shorter, and an action cannot go to the wrong topic by mistake.
Switch the experiment's view after a guide tip or vote. The raw output names
guide or poll:demo; the channel action uses the topic of its accepted
instance. The recipient and payload stay the same.
This is a real limit. A raw update function can act on several topics in one
effect list. A channel callback can act only on its own channel. To publish
across topics, use the beryl.Sockets handle from outside the channel, or from
an actor that owns the handle.
Actions also depend on when they run. In on_message and on_info, a callback
can reply, push, broadcast, track presence, or close. In on_terminate, the
channel is closing. A reply, push, or presence track makes no sense there, and
the compiler rejects them. Broadcast and presence cleanup still work.
beryl runs actions in list order
Section titled “beryl runs actions in list order”The vote branch returns:
Ok(state) -> channel.next(room, [ channel.reply_ok(message.reply, poll.to_json(state)), channel.broadcast_from("poll_state", poll.to_json(state)), ])The channel layer turns each action into one core socket.Effect. It keeps the
order. The same runtime runs them, so the reply goes out before the broadcast.
This is effect execution order, not an arrival-order promise across different client connections.
A join can also return actions. Use channel.with_actions on an accepted join.
beryl sends the join acknowledgment first, then those actions. A push can never
arrive before its own join acknowledgment.
Pick one API for each endpoint
Section titled “Pick one API for each endpoint”Raw dispatch is the core programming model and the clearest one to learn from. Choose it when you have one topic family, a small protocol, or work that spans topics.
Choose beryl/channel when a socket serves several topic namespaces, when you
port Phoenix Channels, or when each handler should keep its own state and info
types. Both APIs share the wire codec, runtime, presence, PubSub, abuse
controls, and transport. Use one API for each socket endpoint. Do not mix raw
update logic into a channel system.
The next chapter follows both APIs down into the shared runtime. It shows where the Elm analogy stops.
Sources and further reading
Section titled “Sources and further reading”Runnable checkpoint: step 04
Section titled “Runnable checkpoint: step 04”cd examples/live_poll && gleam run -m live_poll/step_04Open http://localhost:8104 in two tabs, join demo, and vote. Replies and
peer broadcasts work as in step 03. Now beryl/channel owns the routing and
the channel state. Select Close poll now or wait 60 seconds. The browser
also joins the guide channel. Inspect the title attribute of the status
paragraph to see its typed info message.
Next: Where the analogy ends.
