A small chat server built on Gleam OTP actors and mist, with live updates over server-sent events. Written to learn Gleam and OTP.
Each chat gets its own supervised actor holding the conversation and its subscribers. A registry actor knows which chats exist. The HTTP layer is stateless and addresses the registry by name.
gleam run # listens on http://localhost:3000
gleam testRequires Gleam >= 1.18 and OTP 27.
# create a chat
curl -X POST localhost:3000/chat -d '{"id":"gleam","title":"Gleam talk"}'
# => 201 {"id":"gleam","title":"Gleam talk"}
# watch it (leave this running in another terminal)
curl -N localhost:3000/chat/gleam/stream
# say something
curl -X POST localhost:3000/chat/gleam -d '{"author":"ada","content":"hello"}'
# => 201 {"id":1,"author":"ada","content":"hello","sent_at":"2026-08-23T04:51:38.380Z"}Every response is JSON, including errors, which are always {"error": "..."}.
| Method | Path | Body | Success |
|---|---|---|---|
POST |
/chat |
{"id", "title"} |
201 the chat |
GET |
/chat |
200 array |
|
GET |
/chat/:id |
200 the chat |
|
DELETE |
/chat/:id |
204 |
|
POST |
/chat/:id |
{"author", "content"} |
201 the message |
GET |
/chat/:id/stream |
200 event stream |
Failures: 404 unknown chat or route, 409 chat id already taken, 413 body
too large, 422 body could not be decoded (the message names the field at
fault), 503 a room could not be started.
Ids must be URL-safe (letters, digits, -, _) because they appear in paths.
GET /chat/:id/stream is a server-sent event stream. Events are named, so a
browser can use addEventListener rather than inspecting each payload:
event: history # sent once, on connecting: the recent messages, oldest first
data: [{"id":1,"author":"ada","content":"hello","sent_at":"..."}]
event: message # one per message accepted after you connected
data: {"id":2,"author":"bob","content":"hi there","sent_at":"..."}
event: closed # the chat was deleted; the stream ends here
data: {}
A subscriber receives history before any message, and never sees the same
message in both. The room registers the subscriber and sends the history while
handling a single message, so nothing can interleave between the two.
sequenceDiagram
participant C as SSE connection
participant R as room actor
participant P as poster
C->>R: Subscribe(client)
Note over R: registers the client and<br/>sends history in one step,<br/>so nothing slips between them
R-->>C: History([msg 1])
P->>R: Post(draft)
R-->>P: Message(id: 2, ...)
R-->>C: Posted(msg 2)
P->>R: Close
R-->>C: Closed
flowchart TD
main["blab.main()"]
subgraph tree["blab/supervisor · one_for_all"]
sup["blab supervisor"]
rooms["rooms<br/><i>factory supervisor · simple_one_for_one</i>"]
reg["chat registry<br/><i>named actor</i>"]
web["web<br/><i>mist supervisor</i>"]
end
main --> sup
sup --> rooms
sup --> reg
sup --> web
rooms -.->|"started on demand,<br/>Temporary"| r1["room · gleam"]
rooms -.-> r2["room · elixir"]
web --> c1["connection"]
web --> c2["SSE connection"]
c1 -->|"named subject"| reg
c2 -->|"named subject"| reg
reg -->|"start_child"| rooms
reg -. "monitors" .-> r1
reg -. "monitors" .-> r2
c1 -->|"Post"| r1
c2 -->|"Subscribe"| r1
r1 -. "monitors" .-> c2
r1 -->|"Posted / History / Closed"| c2
classDef supervisor fill:#2d3f5e,stroke:#7aa2d6,color:#eaf0fa
classDef actor fill:#3d3050,stroke:#b48ead,color:#f3ecf7
class sup,rooms,web supervisor
class reg,r1,r2,c1,c2 actor
| Module | Responsibility |
|---|---|
blab |
Entry point: start the tree, stay alive |
blab/supervisor |
The supervision tree and its startup configuration |
blab/chat/message |
The message type, its wire format, and body validation |
blab/chat/room |
One actor per chat: history and subscribers |
blab/chat/registry |
Which chats exist and which room serves each |
blab/web/router |
Routes, and the SSE bridge from room events to the wire |
blab/web/response |
Response construction and request body decoding |
The registry is a named actor. Callers hold its process.Name and build a
subject per call, so nothing they hold goes stale if the registry is restarted.
Passing a Subject down instead means the holder is left talking to a dead
process after a restart.
Rooms are supervised children, not bare spawns. The registry asks a factory
supervisor to start each room, so rooms appear in the supervision tree and in
observer, and are shut down in an orderly way when the application stops.
Rooms are Temporary. A room's entire state is the conversation it holds in
memory. Restarting a crashed one produces an empty room under a new pid, which
is indistinguishable to clients from the room being gone. It stays down, the
registry's monitor notices, and the chat is dropped.
Everything that hands out a reference also monitors it. The registry monitors rooms, so it never hands a caller a subject whose process has died. Rooms monitor subscribers, so a stream that dies without unsubscribing does not leave an entry behind.
one_for_all at the top. The registry's map of chats and the rooms
factory's actual children are two halves of one piece of state; either half
surviving alone is worse than a clean restart. In practice it rarely fires,
because a crashing Temporary room does not restart and does not count towards
the factory's restart intensity.
History is capped at room.history_limit messages, and message bodies at
message.max_content_length. Both live in memory, so both need a bound.
Updated from Gleam 1.5 to 1.18 and onto gleam_otp 1.x, whose actor and
supervisor APIs are entirely different from the 0.14 ones this started on.
The rewrite fixed these along the way:
- Duplicate chat ids crashed the connection. The handler did
let assert Ok(_) = actor.call(...)on a result that isErrorwhenever the id is taken, so asking twice panicked instead of answering. Now409. - Subscribing raced the history.
SubscribeandGetAllMessageswere two separate messages, so a message published between them arrived before the history and appeared inside it. Now one message does both. - History came back newest-first. It is prepended for cheap writes, but readers were handed it unreversed, so a replaying client saw the conversation backwards relative to the live messages that followed.
- Dead rooms were handed out. Nothing noticed a room exiting, so the registry kept returning its subject and posts to it vanished silently.
- Dead subscribers were never dropped. A room only removed a client when a write to it failed, so a stream that crashed leaked its entry forever.
blab_supervisordid not compile — it imported the same module twice and calledsupervisor.workerwith labels that do not exist. Nothing referenced it; the application ran unsupervised.io.debugon every message, printing the subscriber list to stdout.- A ~800MB request body limit (
10_024 * 10_024 * 8), buffered in memory. Now 64KB. - Every decode failure returned the body
"Failed". Errors now name the field and what was expected. - JSON was served without a
content-type, and some endpoints answered with bare sentences like"Message sent"instead of JSON. - Clients chose their own message ids and timestamps, so they could be spoofed or collided, and history order depended on what clients sent. Both are now assigned by the room, and the assigned message is returned to the poster.
Publishaccepted anAllMessagesvalue that the room silently ignored — a state the type allowed but the code could not act on.- Unbounded growth: conversations and message bodies had no limit.
- No input validation: chat ids containing
/produced chats that could not be addressed, and blank authors and messages were accepted.
Test coverage went from one 1 == 1 placeholder to 48 tests: unit tests for the
decoders, actor-level tests for the room and registry (including the ordering
guarantee, the monitor cleanup, and the history cap), and end-to-end tests that
run a real supervision tree over a real socket. The HTTP tests bind port 0 so
the operating system picks a free port, giving each test its own isolated server.
- Basic chat with a registry, chats in actors, and mist
- Supervision
- Tests
- Confirm the tree in
observer— rooms are supervised children now, so they should appear under the rooms factory rather than as loose processes - Persistence/hydration port (hexagonal)
- mnesia/amnesiac adapter
- postgres/squirrel adapter
- Try wisp with SSE; fork and PR if necessary
- More features: message types, privacy, invites/acceptance, LLM agents