Skip to content

Latest commit

 

History

4 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Blab

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.

Running it

gleam run    # listens on http://localhost:3000
gleam test

Requires 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"}

HTTP API

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.

The event stream

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
Loading

Architecture

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
Loading
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

Design notes

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.

What changed in the 2026 pass

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 is Error whenever the id is taken, so asking twice panicked instead of answering. Now 409.
  • Subscribing raced the history. Subscribe and GetAllMessages were 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_supervisor did not compile — it imported the same module twice and called supervisor.worker with labels that do not exist. Nothing referenced it; the application ran unsupervised.
  • io.debug on 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.
  • Publish accepted an AllMessages value 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.

Roadmap

  • 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

About

Chat system in Gleam OTP

Resources

Stars

0 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages