Build a Twitter clone on the BEAM
A step-by-step tutorial in Elixir + Francis — realtime, multi-user, and persistent, with zero external infrastructure.
Elixir runs on the BEAM — the virtual machine behind Erlang, built to run millions of tiny, isolated processes that talk by passing messages. That foundation makes realtime features (live feeds, chat, presence) things you get from the standard library, instead of bolting on Redis, a message broker, and a separate websocket service.
We'll build Chirp, a minimal Twitter-style feed, and meet the BEAM's building blocks one at a time — no prior BEAM knowledge assumed. The panel on the right is the finished app; post in it as you read.
"Twitter clone" is mostly the framing — what we're really building is a live chat: a shared, append-only feed that everyone watching sees update in realtime. If you know Phoenix's famous 15-minute LiveView Twitter clone, this is the same app built without Phoenix — so the BEAM primitives (processes, Registry, ETS) are out in the open instead of behind LiveView.
What each file is responsible for:
site_web.ex— the Francis app. The one module thatuses Francis. It holds both approaches as two route-groups: an HTTP-POST + SSE-stream pair (approach 1) and a single WebSocket (approach 2) — a real app picks one; Chirp keeps both to compare. Everything below is shared, plain modules.chirp.ex— the data. One chirp, as a struct.timeline.ex— the store. Keeps every chirp (in memory, then ETS + a dump file). The closest thing to a database.pubsub.ex— the broadcaster. Pub/sub (via Registry) that pushes each new chirp out to every live connection. It doesn't hold chirps — that's the store. Think "the loudspeaker," not "the archive."render.ex— the view. Turns a chirp into its HTML.identity.ex— the handle. Gives each visitor a throwaway name.application.ex— the boot. Starts and supervises everything: the registry, the store, and the web servers.
1 Meet Francis — the Sinatra of Elixir
Francis is a micro web
framework: think Sinatra (Ruby) or Flask (Python). You
use Francis and declare routes as little anonymous functions
— no controllers, no generators, no folders of boilerplate. Here's
essentially its whole surface area:
defmodule MyApp do
use Francis
get "/", fn _ -> %{hello: :world} end
get "/:name", fn %{params: %{"name" => name}} -> "hello #{name}" end
post "/", fn conn -> conn.body_params end
ws "/chat", fn
:join, socket -> {:reply, "welcome"}
{:received, msg}, _ -> {:reply, msg}
end
sse "/events", fn
:join, socket -> {:reply, %{id: socket.id}}
{:received, m}, _ -> {:reply, m}
end
unmatched fn _ -> "not found" end
end
Five ideas, and that's the framework:
get/post— HTTP routes. Whatever the function returns becomes the response: a map turns into JSON, a string into the body."/:name"— a path parameter, handed to you inparams.ws— a WebSocket handler. You pattern-match the lifecycle (:join,{:received, msg}) and reply.sse— a Server-Sent Events stream (server → browser, one way).unmatched— the catch-all, i.e. your 404.
Under the hood use Francis turns the module into a plain
Bandit web server. No magic
runtime — just a function per route.
2 Your first route
The smallest possible app is one module and one route. Returning a string sends it straight back as the response body:
defmodule Chirp.Web do
use Francis
# A route is a verb + path + function. Whatever it returns is the
# response: a string becomes the body, a map becomes JSON.
get "/", fn _conn -> "Hello, Chirp!" end
end
Every route function receives one argument, conn — the
connection. It's a struct (a
Plug.Conn)
holding everything about the request — path, query and form
params, headers, cookies — plus the response you're building
up. This route doesn't need any of it, so we write _conn: the
leading underscore is Elixir's way of saying "I'm intentionally ignoring
this argument." We'll actually reach into conn in a moment,
to read the posted chirp and the reader's cookie.
Start it with mix run --no-halt and GET / says
hello. From here on, every piece we add is a small function like this one.
3 A chirp is just data
Elixir is functional: no classes, no ORM. A chirp is a plain struct — an immutable map with known fields. We declare the shape, plus a constructor that fills in an id and a timestamp:
defmodule Chirp.Chirp do
# A struct is an immutable map with a fixed set of keys. @enforce_keys
# means you can't build a %Chirp{} unless all four are present.
@enforce_keys [:id, :handle, :text, :at]
defstruct [:id, :handle, :text, :at]
# A plain constructor function. `Chirp.new("ada", "hello")` hands back a
# fresh %Chirp{} — there are no classes or `new` keyword in Elixir.
def new(handle, text) do
# %__MODULE__{...} builds a struct of THIS module. __MODULE__ is a
# shorthand for the current module's name (here Chirp.Chirp), so this is
# the same as writing %Chirp.Chirp{...} — without repeating the name.
%__MODULE__{
id: 8 |> :crypto.strong_rand_bytes() |> Base.encode16(), # random id
handle: handle,
text: text,
at: System.system_time(:second) # unix time
}
end
end
A note on @spec: real Elixir often carries
@spec lines — optional type annotations used by the
Dialyzer checker and the docs. They're good practice but you
don't need them to run anything, so we've left them out of these
teaching snippets to keep them lean. You'll see them in the full files at
the end.
4 Remembering chirps — a GenServer
On most stacks, "remember something between requests" means reaching for a database immediately. On the BEAM you can start simpler. A GenServer is a process that owns some state and handles one message at a time — a tiny in-memory server. Ours holds the list of recent chirps; it starts empty and lives as long as the app runs:
# `use GenServer` brings in the process plumbing; we fill in the callbacks.
# The starting state: an empty list of chirps.
def init(_), do: {:ok, []}
# Handle a :list request — reply with the chirps, leave state unchanged.
# (The 2nd element of the tuple is the reply, the 3rd is the next state.)
def handle_call(:list, _from, chirps) do
{:reply, chirps, chirps}
end
# Handle a :post request — build the chirp, tell every connected client,
# then reply with it and store the new state (prepended, capped at 100).
def handle_call({:post, handle, text}, _from, chirps) do
chirp = Chirp.new(handle, text)
PubSub.broadcast(chirp)
{:reply, chirp, Enum.take([chirp | chirps], 100)}
end
Because the process stays alive, that state survives across requests for free — no database needed to get going. We wrap the raw message-passing in a friendly API so the rest of the app just calls functions:
# Friendly wrappers so callers never write `GenServer.call` by hand.
# `__MODULE__` is this module's own name — we started the process under it,
# so it doubles as the address to send messages to.
# Read the current feed.
def list, do: GenServer.call(__MODULE__, :list)
# Save a chirp (silently ignoring blank text).
def post(handle, text) do
case String.trim(text || "") do
"" -> :ignore
text -> GenServer.call(__MODULE__, {:post, handle, text})
end
end
A word on pattern matching. Elixir leans on it instead of
if/switch: you write several clauses and the one
whose shape matches the data runs, binding variables as it goes.
Above, a call carrying :list runs the first
handle_call, while a {:post, handle, text} tuple
runs the second — pulling handle and text out of
the tuple in the process. The case does the same: an empty
string matches "", anything else matches text.
You'll see this shape-matching everywhere below — including the
:join / {:received, msg} clauses in the realtime
handlers.
One catch worth naming now: it's in memory only, so a restart wipes the feed. We'll fix that in the last step — but it carries us a long way first.
5 Turning a chirp into HTML
The browser wants HTML, not a struct. Francis ships ~E, an
EEx template sigil that auto-escapes anything you interpolate, so user
text can't inject markup. One chirp becomes one list item:
# ~E is Francis's HTML sigil. Like Phoenix's ~H it auto-escapes every
# interpolation, so a chirp containing "<script>" can't inject markup.
def li(chirp) do
assigns = %{handle: chirp.handle, text: chirp.text}
~E"""
<li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li>
"""
|> flatten() # collapse to one line (SSE frames can't contain newlines)
end
@handle and @text come straight from the chirp,
escaped on the way in. Server-rendered HTML, no client framework.
~E is for snippets; whole pages have a companion.
~E (from francis_htmx) is great for a fragment
like this. For full pages from files, Francis pairs with
francis_template:
use FrancisTemplate then
render(conn, "page.html.eex", assigns), with layouts and
pluggable engines. The page you're reading is rendered by it — see
site_web.ex in the final section.
6 Posting a chirp
Time to accept input. The route just records the chirp — it returns nothing to swap, because (as we'll see next) the new chirp arrives over a live stream instead:
# Ingest only: record the chirp and return nothing to swap. The new chirp
# reaches everyone over the live stream (next step), not via this response.
post("/chirps", fn conn ->
Timeline.post(conn.params["handle"] || "anon", conn.params["chirp"])
""
end)
The form uses htmx, a tiny library that lets plain HTML do AJAX through attributes — no custom JavaScript:
<!-- htmx: post the form over AJAX; hx-swap="none" means "don't replace
anything with the response" — the live stream paints the chirp. -->
<form hx-post="/chirps" hx-swap="none" hx-on::after-request="this.reset()">
<input type="hidden" name="handle" value="<%= @handle %>" />
<input name="chirp" placeholder="What's happening?" required />
<button type="submit">Chirp</button>
</form>
7 Telling everyone — Registry pub/sub
A new chirp has to reach every connected reader, not just the one who posted it. The BEAM has pub/sub built in: Registry, from the standard library — no Redis, no broker. Each connected client is a process that subscribes; broadcasting fans a message out to all of them:
# Each connected client calls this to join a Registry group (:ws or :sse).
# Registry is Elixir's built-in pub/sub — no Redis, no external broker.
def subscribe(kind), do: Registry.register(@registry, kind, nil)
# Fan one chirp out to every subscriber. `dispatch` runs our function with
# the list of subscriber processes; we send each process a message.
def broadcast(chirp) do
Registry.dispatch(@registry, :ws, fn subscribers ->
html = Render.oob(chirp) # WS: finished HTML
for {pid, _} <- subscribers, do: send(pid, html)
end)
Registry.dispatch(@registry, :sse, fn subscribers ->
for {pid, _} <- subscribers, do: send(pid, {:chirp, chirp}) # SSE: struct
end)
end
Timeline.post/2 calls PubSub.broadcast/1 after
saving, so a single post reaches everyone. The two branches — one for
WebSocket clients, one for SSE — exist because the transports deliver
slightly differently; that's the next two steps.
8 Streaming down — Server-Sent Events
There are two ways to make the feed live, and we'll build both. Here they are side by side before we dive in — same core underneath, different pipe:
SSE is a one-way pipe: the server pushes events, the
browser listens. It can't carry the chirp up — that's what the
HTTP POST from step 6 was for. On connect we subscribe and
replay the backlog; each broadcast becomes a named chirp
event:
# sse/2 declares a Server-Sent Events stream. We pattern-match its lifecycle.
sse("/stream", fn
# On connect: subscribe, then replay the backlog oldest-first (so each
# "prepend" leaves the newest chirp on top).
:join, _socket ->
Chirp.PubSub.subscribe(:sse)
Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), {:chirp, &1}))
:noreply
# A broadcast arrives here as a message — render it and frame it as a
# named "chirp" event that the browser's htmx is listening for.
{:received, {:chirp, chirp}}, _socket ->
{:reply, %{event: "chirp", data: Chirp.Render.li(chirp)}}
end)
A ws/sse handler is the realtime cousin of an
HTTP route. Where a get/post function gets a
conn, this one is handed a socket — the state of
this one live connection — alongside a lifecycle message you
pattern-match: :join when it opens,
{:received, msg} for something coming in, and
{:close, _} when it ends. We don't need the socket's details
here, so again it's _socket.
htmx's SSE extension does the browser-side wiring — connect, and prepend each event:
<!-- htmx sse extension: open /stream and prepend each "chirp" event here -->
<ul id="feed" hx-ext="sse" sse-connect="/stream" sse-swap="chirp" hx-swap="afterbegin"></ul>
9 Two-way — a WebSocket
A WebSocket is a single bidirectional socket, so it
carries chirps both directions — no separate HTTP post. On
:join we subscribe and replay; an inbound message (the form,
sent as JSON by htmx) becomes a Timeline.post. The live panel
on the right is exactly this:
# ws/2 declares one bidirectional WebSocket.
ws("/socket", fn
# On connect: subscribe and replay the backlog as finished HTML.
:join, _socket ->
Chirp.PubSub.subscribe(:ws)
Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), Chirp.Render.oob(&1)))
:noreply
# An inbound message is the form, sent up the socket as JSON by htmx —
# decode it and post it. No separate HTTP route needed.
{:received, raw}, _socket ->
%{"chirp" => text} = msg = Jason.decode!(raw)
Chirp.Timeline.post(msg["handle"] || "anon", text)
:noreply
end)
With WebSockets the server sends finished HTML straight to the browser, where htmx swaps it in out of band — which is where one small gotcha lives:
def oob(chirp) do
assigns = %{handle: chirp.handle, text: chirp.text}
# The gotcha: hx-swap-oob lives on a wrapper <div>, NOT on the <li>. htmx's
# "swap into a selector" form prepends the wrapper's CHILDREN and throws the
# wrapper away — put it on the <li> and you'd lose the <li> (and its styling).
~E"""
<div hx-swap-oob="afterbegin:#feed"><li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li></div>
"""
|> flatten()
end
10 Who's posting? — throwaway identity
No login, but every chirp still needs a name. We mint a random handle once per browser and stash it in a cookie; open two windows and they post as two different users:
# Return {conn, handle}. If there's no "handle" cookie yet, mint a random one
# and set it (good for 24h); otherwise reuse whatever the browser sent.
def ensure(conn) do
conn = Plug.Conn.fetch_cookies(conn)
case conn.cookies["handle"] do
nil ->
handle = random() # e.g. "slyfinch21"
{Plug.Conn.put_resp_cookie(conn, "handle", handle, max_age: 86_400), handle}
handle ->
{conn, handle}
end
end
The handle rides along in a hidden form field, so both the HTTP post and the WebSocket use the exact same mechanism.
11 Surviving restarts — ETS + a dump file
Our GenServer list vanishes on restart. Two more BEAM tools fix that without a database. ETS is an in-memory table with fast, concurrent reads; we keep the live feed there so reads don't queue behind the writer:
# ETS is an in-memory table built into the BEAM, with fast concurrent reads.
# We keep the whole feed under one key and read it straight from the table —
# no message to the GenServer, so reads never queue behind a write.
def list do
case :ets.lookup(@table, @key) do
[{@key, chirps}] -> chirps
[] -> []
end
end
Those :ets and :erlang calls are
Erlang. A module name that's a plain atom — :ets,
:erlang, :crypto (step 3), :rand —
is an Erlang module. Elixir and Erlang share one VM, so you can
reach straight into Erlang's standard library with no wrapper or binding:
:ets.lookup/2, :erlang.term_to_binary/1, and so
on are just function calls. Decades of battle-tested OTP, available as-is.
For durability we simply dump the list to a file: load it on boot to warm the table, then write it back on a timer (and once more on a clean shutdown):
def init(_) do
# trap_exit so a clean shutdown still reaches terminate/2 for a final save.
Process.flag(:trap_exit, true)
# :protected = only this process writes; any process may read concurrently.
:ets.new(@table, [:named_table, :set, :protected, read_concurrency: true])
# Warm the table from the dump file so a restart doesn't start empty.
:ets.insert(@table, {@key, load_dump()})
schedule_flush()
{:ok, %{}}
end
# Every 10 seconds: write the feed to disk, then schedule the next flush.
# This timer — not the shutdown save — is the real safety net against crashes.
def handle_info(:flush, state) do
dump(list())
schedule_flush()
{:noreply, state}
end
# Save and restore are one line each — no DETS, no database.
defp dump(chirps), do: File.write(@dump_path, :erlang.term_to_binary(chirps))
That's the classic "ETS working set + file backup" pattern — in-memory
speed plus survive-a-restart durability, no DETS or database ceremony. The
honest caveat: a hard crash loses whatever changed since the last flush,
so the periodic timer is the real safety net. And the whole upgrade
touches only timeline.ex — the transport and render layers
don't change at all.
All together
Here's the actual running app, end to end — the same source that serves
this page. site_web.ex is the Francis app (both approaches
as route-groups); the rest are the shared, single-concern modules. The
write paths also run light rate-limiting via Chirp.Guard,
and a couple of files that exist only to render this tutorial
(tutorial.ex, highlight.ex) are left out.
defmodule Chirp.SiteWeb do
@moduledoc """
The Francis app: one module, one Bandit listener, one port. This is the only
module that `use`s Francis — everything else is a plain module.
It holds BOTH realtime approaches as two route-groups over the same core
(the `Timeline` store, the `PubSub` broadcaster, the `Render` view):
* `GET "/"` — the page you're reading (a live, WebSocket-backed timeline on
the right, rendered by `Chirp.Tutorial`).
* `POST "/chirps"` + `GET "/stream"` — **approach 1: SSE + POST.** A chirp goes
up over an ordinary HTTP POST; the feed streams back down over Server-Sent
Events.
* `WS "/socket"` — **approach 2: a single WebSocket** carrying chirps both ways.
A real app would pick one approach; Chirp keeps both so you can compare them
side by side. The two write paths also run light guardrails (`Chirp.Guard`) —
a rate limit and a length/blank check — since this is a public demo.
"""
use Francis
# File-based templates (priv/templates/*.html.eex) via francis_template — this
# very page is rendered by it. `use FrancisTemplate` imports `render/2,3,4`.
use FrancisTemplate
alias Chirp.{Identity, Tutorial, Timeline}
# The tutorial page. Identity is read/minted here (same cookie mechanism as
# both transports), then `render/3` renders priv/templates/tutorial.html.eex
# (wrapped in layout.html.eex) with the assigns Chirp.Tutorial gathers.
get("/", fn conn ->
{conn, handle} = Identity.ensure(conn)
render(conn, "tutorial.html.eex", Tutorial.assigns(handle))
end)
# --- SSE transport (reused verbatim from Chirp.SSEWeb) ---
# Ingest only — the SSE stream is what actually paints the chirp.
#
# Guardrails (Chirp.Guard) gate the write: a per-IP fixed-window rate limit and
# a server-side length/blank check. On any rejection we still return "" — the
# POST is hx-swap="none" anyway (the SSE stream paints), so there is nothing to
# swap on error; we just silently drop the over-limit/invalid chirp.
post("/chirps", fn conn ->
ip = conn.remote_ip |> :inet.ntoa() |> to_string()
with :ok <- Chirp.Guard.check_rate(ip),
{:ok, text} <- Chirp.Guard.normalize(conn.params["chirp"]) do
Timeline.post(conn.params["handle"] || "anon", text)
end
""
end)
# NOTE: sse/2 relocates this fn into a generated module, so aliases don't
# reach here — call the Chirp.* modules fully-qualified.
sse("/stream", fn
:join, _socket ->
Chirp.PubSub.subscribe(:sse)
# Replay backlog oldest-first so afterbegin leaves the newest on top.
Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), {:chirp, &1}))
:noreply
{:received, {:chirp, chirp}}, _socket ->
{:reply, %{event: "chirp", data: Chirp.Render.li(chirp)}}
{:close, _reason}, _socket ->
:ok
end)
# --- WebSocket transport (reused verbatim from Chirp.WSWeb) ---
# NOTE: ws/2 relocates this fn into a generated module, so aliases don't reach
# here — call the Chirp.* modules fully-qualified.
ws("/socket", fn
:join, _socket ->
Chirp.PubSub.subscribe(:ws)
# Replay backlog: send finished HTML straight to the client (bypasses us).
Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), Chirp.Render.oob(&1)))
:noreply
{:received, raw}, _socket ->
# htmx's ws-send delivers the form fields as JSON (plus a HEADERS key).
msg = Jason.decode!(raw)
handle = msg["handle"] || "anon"
# Same guardrails as the POST path. The Francis socket doesn't expose the
# peer IP (its struct is %{id, transport, path, params}), so per the Guard
# module's documented fallback we key the rate limit per handle here. On any
# rejection (over-limit or blank/too-long) we drop the message silently.
with :ok <- Chirp.Guard.check_rate(handle),
{:ok, text} <- Chirp.Guard.normalize(msg["chirp"]) do
Chirp.Timeline.post(handle, text)
end
:noreply
{:close, _reason}, _socket ->
:ok
end)
end
defmodule Chirp.Chirp do
@moduledoc "A single chirp. Plain data — no persistence, lives only in the Timeline GenServer."
@enforce_keys [:id, :handle, :text, :at]
defstruct [:id, :handle, :text, :at]
@type t :: %__MODULE__{
id: String.t(),
handle: String.t(),
text: String.t(),
at: integer()
}
@spec new(String.t(), String.t()) :: t()
def new(handle, text) do
%__MODULE__{
id: 8 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower),
handle: handle,
text: text,
at: System.system_time(:second)
}
end
end
defmodule Chirp.Timeline do
@moduledoc """
The whole "database": the most recent chirps, kept hot in an **ETS** table and
periodically **dumped** to a single file so they survive a restart. No Ecto,
no Postgres — `mix run` and the feed is live *and* durable.
The ETS-as-working-set, file-as-backup pattern, split by concern:
* **ETS** holds the live feed. Reads (`list/0`) hit the table directly from
the *caller's* process — backlog replay for every new SSE/WS client no
longer queues behind this GenServer's mailbox. The table is `:protected`,
so only this process (the owner) writes; everyone else reads concurrently.
Writes (`post/2`) still serialize through this GenServer's `handle_call`,
so there is exactly one writer and reads never see a torn list.
* **The dump file** is pure persistence, never on the hot path. We load it
once on boot to warm ETS, then flush ETS → disk on a timer (and once more
on graceful shutdown). `:erlang.term_to_binary/1` of the capped list is
all it takes.
## Why not just ETS?
ETS is *volatile*: the table dies with its owner process and with the VM, so
the feed would evaporate on any restart. The file dump is what makes it durable.
## Why not DETS (or another disk store)?
DETS is the obvious "ETS but on disk" answer, but it would put I/O on the read
path and buy us problems we don't have here:
* **Disk latency on the hot path.** Every `list/0` would touch disk. Our
reads happen on *every* new client connect (backlog replay); we want them
to stay in-memory fast.
* **Weaker concurrency.** DETS has no `read_concurrency`-style parallel reads
the way ETS does.
* **2 GB file cap.** DETS tables are limited to ~2 GB.
* **Dirty-file repair.** A DETS file not closed cleanly must be auto-repaired
on open — a slow, scary boot. Our dump is a single `term_to_binary` blob:
if it is corrupt we just start empty, no repair dance.
Keeping ETS for reads and a plain dump file for persistence gives us in-memory
speed *and* survive-a-restart durability, with no DETS ceremony.
## The durability flow
1. **Load on boot** — `init/1` reads the dump file (if any) and warms ETS.
2. **Flush on a timer** — every `@flush_interval` we write ETS → disk.
3. **Flush on terminate** — `terminate/2` does one final write on clean exit.
The trade-off is the **durability window**: a crash loses whatever was written
since the last flush (up to `@flush_interval`). `terminate/2` closes the gap on
a clean shutdown, but it is best-effort — a hard kill skips it, which is why
the periodic flush, not `terminate/2`, is the real safety net.
"""
use GenServer
alias Chirp.{Chirp, PubSub}
@table __MODULE__
@key :chirps
@max_chirps 100
@flush_interval :timer.seconds(10)
# The dump file location is configurable so a deploy can point it at a mounted
# volume (e.g. Fly) via CHIRP_DUMP_PATH in runtime.exs. Defaults to priv.
defp dump_path, do: Application.get_env(:chirp, :dump_path, "priv/chirps.dump")
def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
@doc "Newest-first list of recent chirps. Reads ETS directly — no GenServer call."
@spec list() :: [Chirp.t()]
def list do
case :ets.lookup(@table, @key) do
[{@key, chirps}] -> chirps
[] -> []
end
end
@doc """
Record a chirp and broadcast it to all connected clients. Returns the chirp,
or `:ignore` for blank text.
"""
@spec post(String.t(), String.t()) :: Chirp.t() | :ignore
def post(handle, text) do
case String.trim(text || "") do
"" -> :ignore
text -> GenServer.call(__MODULE__, {:post, handle, text})
end
end
@doc """
Empty the feed. Goes through the GenServer so the single-writer invariant on
the `:protected` ETS table is preserved. Used by `Chirp.Guard.Sweeper` for the
scheduled auto-clear.
"""
@spec clear() :: :ok
def clear, do: GenServer.call(__MODULE__, :clear)
@impl true
def init(_) do
# Trap exits so the supervisor's shutdown reaches terminate/2 for a final flush.
# Without this, a :shutdown would kill us before we can write the dump.
Process.flag(:trap_exit, true)
# :protected — only the owner (us) writes; any process may read concurrently.
# :named_table — readers find the table by name without holding a reference.
:ets.new(@table, [:named_table, :set, :protected, read_concurrency: true])
# Warm ETS from the on-disk dump so a restart doesn't start with an empty feed.
:ets.insert(@table, {@key, load_dump()})
schedule_flush()
{:ok, %{}}
end
@impl true
def handle_call({:post, handle, text}, _from, state) do
# Writes serialize here: this is the single ETS writer, so prepend-and-cap
# is race-free even though reads happen concurrently in other processes.
chirp = Chirp.new(handle, text)
:ets.insert(@table, {@key, Enum.take([chirp | list()], @max_chirps)})
PubSub.broadcast(chirp)
{:reply, chirp, state}
end
@impl true
def handle_call(:clear, _from, state) do
# Single-writer reset: keep the table shape ({@key, list}) so list/0 still
# returns [] rather than crashing on a missing row.
:ets.insert(@table, {@key, []})
{:reply, :ok, state}
end
@impl true
def handle_info(:flush, state) do
# The real safety net: a periodic flush bounds data loss to one interval,
# regardless of how the VM goes down.
dump(list())
schedule_flush()
{:noreply, state}
end
@impl true
# Best-effort final flush on a *clean* shutdown. A hard kill (SIGKILL, power
# loss) skips this entirely — which is why the timer above is what we rely on.
def terminate(_reason, _state), do: dump(list())
# --- Persistence: ETS is the working set, this file is the backup ---
defp schedule_flush, do: Process.send_after(self(), :flush, @flush_interval)
defp dump(chirps), do: File.write(dump_path(), :erlang.term_to_binary(chirps))
# The dump is a file we wrote ourselves, so plain binary_to_term is fine here.
defp load_dump do
case File.read(dump_path()) do
{:ok, bin} -> :erlang.binary_to_term(bin)
{:error, _} -> []
end
end
end
defmodule Chirp.PubSub do
@moduledoc """
Pub/sub fan-out built on `Registry` — Elixir's standard library, no Phoenix,
no Redis. The *broadcaster*: it doesn't store chirps (that's `Chirp.Timeline`),
it pushes them out. Every connected client (an SSE stream or a WebSocket) is
one BEAM process that `subscribe/1`s here; `broadcast/1` fans a new chirp to
all of them.
The two transports differ in HOW the push reaches the client, and that
difference is baked into Francis itself:
* WebSocket: `send(socket.transport, msg)` BYPASSES the handler and is
forwarded straight to the browser. So we send WS subscribers the final
HTML (out-of-band wrapped) and htmx's ws extension swaps it in.
* SSE: `send(socket.transport, msg)` ROUTES THROUGH the handler's
`{:received, msg}` clause. So we send SSE subscribers the plain `%Chirp{}`
and let their handler turn it into an `event: chirp` frame.
"""
alias Chirp.Render
@registry __MODULE__
@doc "Called by a transport process (in its `:join` clause) to subscribe."
@spec subscribe(:ws | :sse) :: {:ok, pid()} | {:error, term()}
def subscribe(kind) when kind in [:ws, :sse], do: Registry.register(@registry, kind, nil)
@doc "Fan a new chirp out to every connected client of either transport."
@spec broadcast(Chirp.Chirp.t()) :: :ok
def broadcast(chirp) do
# WS: deliver finished HTML directly to the socket (bypasses the handler).
Registry.dispatch(@registry, :ws, fn subscribers ->
html = Render.oob(chirp)
for {pid, _} <- subscribers, do: send(pid, html)
end)
# SSE: deliver the struct; the handler renders + frames it as an event.
Registry.dispatch(@registry, :sse, fn subscribers ->
for {pid, _} <- subscribers, do: send(pid, {:chirp, chirp})
end)
:ok
end
end
defmodule Chirp.Render do
@moduledoc """
One chirp's HTML, via `francis_htmx`'s `~E` sigil. Like `~H`, `~E`
auto-escapes interpolations, so `@handle`/`@text` are safe without manual
escaping — do NOT also call `Francis.HTML.escape/1` or you double-escape.
Two variants of the same markup:
* `li/1` — bare `<li>`, used as the SSE event payload. The SSE container
has `sse-swap="chirp" hx-swap="afterbegin"`, so it's prepended for us.
* `oob/1` — the same `<li>` wrapped in a throwaway `<div>` carrier that
holds `hx-swap-oob="afterbegin:#feed"`, so htmx's ws extension knows
where to drop a message that arrives unsolicited over the socket. The
`swapStyle:selector` form of an OOB swap prepends the carrier's
*children* into the target and discards the carrier itself — so the
attribute must live on a wrapper, NOT on the `<li>`. Putting it on the
`<li>` directly drops the `<li>` and prepends only the bare spans, which
lose the `.chirp` row styling. The attribute is written literally (not
interpolated) so `~E` won't escape it.
Output is collapsed to a single line: SSE frames data line-by-line, so a
newline would split the frame.
"""
import FrancisHtmx
alias Chirp.Chirp
@spec li(Chirp.t()) :: binary()
def li(chirp) do
assigns = %{handle: chirp.handle, text: chirp.text}
~E"""
<li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li>
"""
|> flatten()
end
@spec oob(Chirp.t()) :: binary()
def oob(chirp) do
assigns = %{handle: chirp.handle, text: chirp.text}
~E"""
<div hx-swap-oob="afterbegin:#feed"><li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li></div>
"""
|> flatten()
end
defp flatten(rendered), do: rendered |> IO.iodata_to_binary() |> String.trim()
end
defmodule Chirp.Identity do
@moduledoc """
Throwaway identity so the demo needs no login. A random handle is minted once
and stored in a cookie, then rendered into a hidden form field. Both demos
send that field with each chirp (the SSE demo over HTTP, the WS demo over the
socket), so the same mechanism works for both.
"""
@adjectives ~w(quick lazy brave clever calm sly bold keen wary jolly)
@animals ~w(otter koala raven lynx finch heron tapir gecko bison moth)
@doc "Return `{conn, handle}`, setting a `handle` cookie the first time."
@spec ensure(Plug.Conn.t()) :: {Plug.Conn.t(), String.t()}
def ensure(conn) do
conn = Plug.Conn.fetch_cookies(conn)
case conn.cookies["handle"] do
nil ->
handle = random()
{Plug.Conn.put_resp_cookie(conn, "handle", handle, max_age: 86_400), handle}
handle ->
{conn, handle}
end
end
defp random do
"#{Enum.random(@adjectives)}#{Enum.random(@animals)}#{:rand.uniform(99)}"
end
end
defmodule Chirp.Application do
@moduledoc false
use Application
require Logger
@impl true
def start(_type, _args) do
# ONE listener, ONE port: the tutorial app mounts SSE, WS, and the page
# under a single origin (so Fly has a single thing to expose).
port =
Application.get_env(:chirp, :port) || String.to_integer(System.get_env("PORT") || "4080")
children = [
# Pub/sub registry: connected clients subscribe, Timeline dispatches.
{Registry, keys: :duplicate, name: Chirp.PubSub},
# The in-memory feed (ETS working set + file dump).
Chirp.Timeline,
# Guardrails housekeeping: periodically clears the public feed and sweeps
# expired rate-limit windows. The per-IP rate limiter itself needs no child
# (its ETS table is created lazily on first use).
{Chirp.Guard.Sweeper, clear_fun: &Chirp.Timeline.clear/0},
# The consolidated tutorial app: one Francis plug carrying every route.
{Bandit, plug: Chirp.SiteWeb, scheme: :http, port: port}
]
# Honest in server logs: the bind is on all interfaces, so don't claim
# "localhost" (which only resolves when you're running it locally). The dev
# hint is parenthetical; in prod the real URL is the host's address.
Logger.info("Chirp tutorial listening on port #{port} (http://localhost:#{port} in dev)")
Supervisor.start_link(children, strategy: :one_for_one, name: Chirp.Supervisor)
end
end
That's a live, multi-user, persistent message board with two realtime transports — and not a single piece of external infrastructure. That's the BEAM's pitch, in one small app.