The Elm architecture, without a DOM
Lustre and Gleam OTP actors use the same programming model. Each one starts with some state. Each one receives a typed message. Each one runs one function that returns the next state and any work to do. Lustre uses this model to run a user interface. An OTP actor uses it to run a concurrent process. beryl uses it to handle realtime socket traffic.
The last step is different in each case. Lustre renders a view. An actor keeps its next state or stops. beryl returns protocol effects. An effect can accept a join, send a reply, or broadcast a message. beryl also creates state once for each connected socket, not once for the whole application.
We start with beryl's core API. It keeps state, inputs, and effects in plain view. First we compare the Lustre and OTP forms. Then we show the beryl API and map its terms onto the same model.
Build a live poll
Section titled “Build a live poll”We will build a live poll with rooms. A browser joins a topic such as
poll:demo. It reads the current totals, votes for Gleam or Erlang, and sees
votes from other tabs. Later chapters add automatic closing and a second kind
of subscription.
A topic is a string. It names one subscription inside a WebSocket
connection. In poll:demo, poll is the kind of subscription and demo is
the room. One socket can join many topics. beryl uses the topic string to route
messages and broadcasts.
beryl does not call this subscription a channel. In beryl, a channel is a separate API built on top of topics. A later chapter introduces that API. For now, the browser joins a topic, and your application routes it by its string.
A poll is a good example because it needs little domain code. Each browser connection needs its own state. All browsers in the same room share one poll. A vote also produces two kinds of output: a reply to the voter and a broadcast to everyone else. These needs give us real reasons to use models, typed messages, topic routing, effects, and an OTP actor that your application owns.
This chapter ends with the smallest useful checkpoint. The server accepts a
poll:* join and returns the current poll state. Voting, broadcasts, timers,
and channels come later, after the core loop is clear.
The shared model-update pattern
Section titled “The shared model-update pattern”The shared model has four parts:
- create some state;
- define the messages, or inputs, that can arrive;
- handle one input against the current state;
- return the next state and say what happens next.
Lustre, OTP actors, and beryl use different names for the fourth part. They also do different things with it. Parts one to three look the same in all three.
Start with the familiar Lustre loop
Section titled “Start with the familiar Lustre loop”The standard Lustre example has four pieces. Here is the counter from the Lustre overview, with full type annotations (see Lustre for Elm developers):
type CounterModel = Int
fn init(_flags: Nil) -> CounterModel { 0}
type Message { Incr Decr}
fn update(model: CounterModel, message: Message) -> CounterModel { case message { Incr -> model + 1 Decr -> model - 1 }}The view calls each module by name:
fn view(model: CounterModel) -> element.Element(Message) { let count = int.to_string(model)
html.div([], [ html.button([event.on_click(Incr)], [element.text(" + ")]), html.p([], [element.text(count)]), html.button([event.on_click(Decr)], [element.text(" - ")]) ])}initcreates the first model.Messagelists every event that can change the state.updatecomputes the next model.viewturns the model into elements. Those elements can send more messages.
Some work cannot happen inside update. A browser may need to send an HTTP
request, start a timer, or call JavaScript. Lustre calls this work an
Effect(Message). An update returns the next model and an effect. The Lustre
runtime does the work. It can then send the result back to update as a new
Message.
An OTP actor uses the same loop. The loop runs inside a concurrent process instead of a browser.
The same loop inside an OTP actor
Section titled “The same loop inside an OTP actor”A Gleam OTP actor is a process with a mailbox. It owns some state. It handles one message at a time, in order. We keep the same counter so the only change is the runtime:
type Message { Incr Decr}
fn on_message(count: Int, message: Message) -> actor.Next(Int, Message) { case message { Incr -> actor.continue(count + 1) Decr -> actor.continue(count - 1) }}The model is still an Int. The messages are still Incr and Decr. Two
things change: where messages come from, and what happens to the result. Lustre
gets messages from the interface and passes the next model to view. An actor
gets messages from its mailbox and calls actor.continue to store the next
state.
Here are the two side by side:
| Lustre | Gleam OTP actor |
|---|---|
| model | actor state |
Message | actor mailbox message |
update | on_message handler |
| next model and effects | actor.Next plus sends performed by the handler |
view | no equivalent |
A Lustre message is usually an interface event or the result of an effect. An
actor message comes from another process. Both go through the same kind of
typed transition. Lustre uses the result to update the screen. The actor
runtime uses actor.Next to continue or stop the process.
To send a message to an actor, you use a process.Subject(message). A subject
is a typed address for the actor's mailbox.
Now we can carry the same model into beryl and give each part a socket meaning.
Apply the loop to sockets
Section titled “Apply the loop to sockets”beryl gives you two ways to program a socket endpoint. The channel layer is
the recommended one. It routes each event to a handler based on the topic.
Under it sits the raw dispatch API. Raw dispatch gives every event on
a socket to one update function that you write.
"Raw dispatch" means your application does the routing. Your code receives
joins, client messages, binary frames, close events, and typed server messages.
Each one arrives as a socket.Input value. Your code decides how each input
changes the socket's model. beryl still owns the WebSocket transport, wire
decoding, protocol checks, and effect execution.
We start with raw dispatch because it shows the full loop. A later chapter moves the same poll to the channel layer. That chapter shows which routing work moves out of your code.
The three domains now line up:
| Role | Lustre | Gleam OTP actor | beryl raw dispatch |
|---|---|---|---|
| state | application model | actor state | one app-defined Model per socket |
| input | Message | actor message | socket.Input(Message) |
| transition | update | on_message handler | update |
| next step | model and optional effect | actor.Next | socket.Next(model, effects) |
beryl asks your application for init and update. This exact excerpt comes
from step_01.gleam:
beryl.child_spec( beryl.config(wire.phoenix_codec()), init: raw.init, update: raw.update(raw.ReadOnly, polls, clock, 60_000),)You can see it in
step_01.gleam.
beryl.child_spec returns two values. The first is a beryl.Sockets handle.
The second is a child specification for your supervisor. Your model and message
types stay inside the closures you pass in. beryl never converts them to
Dynamic.
The live-poll example defines these raw types:
pub type Message { ClosePoll(topic: String)}
pub type Model { Model(sender: socket.Sender(Message), topics: Set(String))}
pub fn init(info: socket.ConnectInfo(Message)) -> #(Model, List(socket.Effect)) { #(Model(sender: info.self, topics: set.new()), [])}This excerpt comes from
raw.gleam.
Each name has one meaning:
Modelis the state your application keeps for one connected socket.Messageis your own type for server-side events. The poll definesClosePollbecause a timer must tell a socket that one of its polls has ended. Another application would define its own variants.socket.Effectdescribes work for beryl to do afterinitorupdate.socket.ConnectInfo.selfis a sender for this socket only. It is not a general processSubject.
The type parameter links the server-message path together.
ConnectInfo(Message) gives you a Sender(Message). When you send a value
through that sender, beryl delivers it to update as Info(Message). Client
frames take a different path. They arrive as the Join, Message, and
Binary variants of socket.Input. Your Message type never holds decoded
client data.
The update function has the type
fn(Model, socket.Input(Message)) -> socket.Next(Model). It matches every
input variant. beryl uses socket.Input(Message) where Lustre uses Message
and an actor uses its mailbox message. Some input variants carry client data.
Info(Message) carries your typed server-side data. socket.Next(model, effects) continues with the next model. socket.Stop(reason) stops the whole
socket.
There is no view
Section titled “There is no view”We have mapped state and input. The last difference is output. beryl does not
render the model. Instead, your application returns an ordered list of
socket.Effect values. This exact excerpt comes from raw.gleam:
socket.Next( Model(..model, topics: set.insert(model.topics, topic)), [socket.AcceptJoin(ref, None)],)Before this line, the full example registers the room in its store. This branch
then accepts a valid poll:* join and adds the topic to the socket's model.
Other effects can reply to a client message, push to this socket, broadcast to
subscribers, update presence, or close a topic.
Without view, you think about output in a new way. A change to the model
does not send a wire frame. A wire frame goes out only when an effect asks for
one. The model can also hold facts that never leave the server. The set of
topics this socket has joined is one example.
Try that difference with the counter. Increment its private state three times before you request the count. Then increment again while the reply travels. The client receives the value captured when the effect was created.
Increment the server model, then request its count. Watch what the client receives.
Last update return (pseudocode)
No input yet
The server starts at 0. The client has not received a count.
Local simulation, not a BEAM server. The counter topic is already joined. Increment is a typed server input; Request count is a client request with a reply reference.
Increment model represents an application-originated Info(Increment).
Request count represents a client get_count request on an accepted topic.
The reply uses the reference from that request.
The counter handler below comes from
tutorial_counter.gleam.
It is not a complete server.
import beryl/socketimport gleam/jsonimport gleam/option.{None}
pub type CounterMessage { Increment}
/// Start the teaching counter with no output.pub fn counter_init( _info: socket.ConnectInfo(CounterMessage),) -> #(Int, List(socket.Effect)) { #(0, [])}
/// Separate private model changes from replies to client requests.pub fn counter_update( count: Int, input: socket.Input(CounterMessage),) -> socket.Next(Int) { case input { socket.Join("counter:demo", _payload, ref) -> socket.Next(count, [socket.AcceptJoin(ref, None)]) socket.Join(_, _, ref) -> socket.Next(count, [ socket.RejectJoin(ref, json.string("unknown topic")), ]) socket.Info(Increment) -> socket.Next(count + 1, []) socket.Message("counter:demo", "get_count", _payload, reply) -> socket.Next(count, socket.reply_ok(reply, json.int(count))) socket.Message(_, _, _, _) | socket.Binary(_, _) | socket.Closed(_, _) -> socket.Next(count, []) }}Initialization belongs to the socket
Section titled “Initialization belongs to the socket”Lustre creates one application model. An OTP actor usually creates one state
value. beryl calls raw init once for every socket that connects. One socket
actor stores that socket's Model and runs its updates. A separate router
actor keeps the list of sockets and routes frames and broadcasts. This split
matters for blocking work and crashes. Chapter 5 covers both.
The live poll now needs an actor that your application owns. Every browser in
a room must see the same totals. So the totals cannot live in one socket's
Model. The example puts them in a shared store.Store actor and captures it
in the update closure. Each browser socket gets its own raw Model. All
sockets send poll operations to the same store. Per-socket state and shared
domain state stay apart.
Where beryl differs from a frontend
Section titled “Where beryl differs from a frontend”The Elm architecture gives us words for pure state transitions. But beryl is not a frontend framework:
- there is no
viewfunction; initruns once per socket;- client input arrives as
socket.Input; - output is an explicit list of
socket.Effectvalues; - one socket actor stores each socket's model and runs its updates.
Raw dispatch shows these facts with little extra code. It is beryl's core. It
is the clearest place to learn joins, replies, close events, and effect order.
When an application has several channel families, you will usually move to
beryl/channel. That is the recommended default for multi-channel and
Phoenix-shaped systems. Chapter 4 makes that move without changing the wire
protocol.
Sources and further reading
Section titled “Sources and further reading”- Lustre overview
- Lustre for Elm developers
- Gleam OTP actor module
- Gleam Erlang process module
beryl/socketsource- beryl raw dispatch guide
Runnable checkpoint: step 01
Section titled “Runnable checkpoint: step 01”cd examples/live_poll && gleam run -m live_poll/step_01Open http://localhost:8101, keep the room as demo, and select Join
poll. The client joins poll:demo, requests get_state, and shows an open
poll with zero votes. Voting and Close poll now do not work yet in this
read-only checkpoint. Those pushes time out and do not change state.
