This file covers both the backend and frontend. The backend guidelines live in the next section; the frontend section is reserved for upcoming frontend-specific notes.
- Frontend talks to backend via WebSocket (
/ws). - Wire types are mirrored between backend (
backend/src/domain/request.gleam,backend/src/domain/response.gleam) and frontend (frontend/src/api/types.ts). - Transport adapters are wire-only: encode/decode JSON, then delegate to handlers/pipeline. REST is not used; WebSocket is the only transport.
- Subjects are the only inter-process addresses; never send to PIDs directly.
- Room broadcast always uses member inbox Subjects; rooms are authoritative for membership.
- WebSocket frames must be sent by the WebSocket controlling process.
- Connection inboxes for room events are Subjects owned by the WS process and wired via a Selector (see WebSocket transport notes).
src/contains Gleam modules for the chat backend.- Domain types live under
src/domain/(e.g.,src/domain/chat.gleam,src/domain/session.gleam). - Transport-agnostic request/response messages live in
src/domain/request.gleamandsrc/domain/response.gleam. - Room processes and registry live in
src/pipeline/room.gleamandsrc/room_registry.gleam. src/handlers/contains transport-agnostic logic grouped by feature (rooms, chat, session) with a shared dispatcher.src/transport/is the gate layer:incoming.gleam/outgoing.gleamare wire-only JSON decode/encode,websocket.gleamadapts to WebSocket.src/pipeline/contains pipeline stages (e.g.,stage_validation.gleam) and the internal envelope/event types used between stages.test/holds Gleeunit tests; handler tests live undertest/handlers/, transport adapter tests undertest/transport/.build/is generated by Gleam; do not edit it manually.gleam.tomlandmanifest.tomldefine package metadata and dependencies.
- Update
AGENTS.mdwhenever architecture, key commands, or module ownership changes in the backend or frontend. - Keep backend request/response wire types and the frontend protocol mirror in sync whenever either changes.
This system is an ephemeral chat app with chatrooms. Each WebSocket connection is handled by a dedicated BEAM process. Chat messages go through a pipeline before being published to a room. Rooms are implemented as one process per room.
Important: In this codebase, inter-process messaging uses Subjects as typed inboxes/addresses. We do not send messages directly to PIDs.
- Room events flow into the WebSocket process via a
Subjectowned by that process plus aSelectorreturned fromon_init. - The WebSocket handler receives those events as
mist.Custom(...)and sends frames from the controlling process. - Do not send frames from any other process or you will hit
NotOnControllingProcesserrors.
- Validation stage forwards accepted messages.
- Processing stage routes messages to rooms by
room_id. - Room process applies policy, then broadcasts
RoomEventto member inbox Subjects.
- A
Subject(T)is the address used to send a message of typeTto a process. - Each long-lived process (connection, room, pipeline stage) exposes one or more Subjects that other processes use to talk to it.
- Commands: sent to a room’s command Subject (e.g.,
Join,Leave,Publish). - Events: sent to a connection’s inbox Subject (e.g.,
RoomEvent), then forwarded to the WebSocket.
Responsibilities
- Perform authentication on connect (once).
- Maintain connection state (user/session info, joined rooms, cached
room_controlper room). - Decode incoming frames into commands (join/leave/send).
- Send chat messages into the pipeline.
- Receive room events via its inbox Subject and forward them to the WebSocket.
Key subjects owned by the connection
conn_inbox: Subject(ConnMsg)(events/control messages directed to this connection)- includes
RoomEvent(...)messages
- includes
Responsibilities
- Maintain a mapping
room_id -> room_controlwhereroom_controlis a handle for contacting a room (at minimum, a room command Subject). - Provide room listing for discovery (
[{room_id, name, ...}]). - Create rooms explicitly via
CreateRoom; joins do not auto-create missing rooms. - Return the
room_controlto a connection process. - Sweep idle rooms hourly; rooms with
last_sentolder than 24 hours are stopped and removed.
Notes
- Connection processes cache
room_controlafter resolving a room to avoid repeated lookups. - A
RoomHandleis a typed handle used to send commands to a room. - Initial rooms are seeded at boot in
backend/src/backend.gleam.
A room is authoritative: it enforces room policies and controls who receives room events.
Responsibilities
- Enforce room-level policies:
- membership required, banned users, room closed, slow mode, capacity limits, etc.
- Maintain authoritative membership:
members: Map(user_id, Member)- where
Membercontains at least:inbox: Subject(ConnMsg)(the connection’s inbox subject used for delivering room events)- optional metadata (display name, join time, etc.)
- optional: monitor the owner PID of each
inboxfor crash-safe cleanup.
- Broadcast accepted messages to all connected members:
- broadcast is done by iterating
membersand sendingRoomEventto each member’s inbox Subject.
- broadcast is done by iterating
Chat messages are augmented early with routing info:
- The client (connection process) builds a
ChatMessagethat includes:room_id(required)sender_id/ username (required)kind(text/image/…)payload(content)message_id(required)- optional metadata (timestamp)
room_id is carried through the whole pipeline so the final stage can route to the correct room.
- Client requests room list:
- Connection → RoomRegistry:
ListRooms
- Connection → RoomRegistry:
- Client selects a room and joins:
- Connection → RoomRegistry:
GetOrCreate(room_id)→ returnsroom_control - Connection caches
room_controlin state.
- Connection → RoomRegistry:
- Connection registers itself as a room member:
- Connection → Room (via room command Subject):
Join(sender_id, conn_inbox, …)
- Connection → Room (via room command Subject):
- Room process:
- applies join policy (banned/capacity/etc.)
- if accepted: stores membership entry in
members - if rejected: replies with error (connection sends error frame to client)
Client
│
│ WebSocket connect
▼
Connection Process
│
│ Auth (once, on connect)
│
│─────────────── ListRooms ───────────────▶
│ RoomRegistry
│◀────────────── room list ────────────────
│
│──────────── GetOrCreate(room_id) ─────────▶
│ RoomRegistry
│◀────────────── room_control (cmd subject) ────
│
│ (cache room_id → room_control)
│
│──────────── Join(sender_id, conn_inbox) ─▶
│ Room Process
│ - apply join policy
│ - store member inbox subject
│◀────────────── Ok / Error ────────────────
│
│ if Ok:
│ connection is now a member of the room
-
On clean disconnect, the connection sends
Leave(room_id, sender_id)to the room. -
For crash-safe cleanup, the room may also monitor the owner PID of each member inbox subject.
- If the connection process dies, the room removes the member from
members.
- If the connection process dies, the room removes the member from
When a user sends a message over WebSocket:
-
Connection decodes the frame and builds
ChatMessage(includesroom_id). -
Connection sends the message into the pipeline (see below).
-
After the pipeline finishes successfully, the message is routed to the room:
- Final pipeline stage sends
Publish(msg)to the room command subject using cachedroom_control.
- Final pipeline stage sends
-
Room process applies room-level publish policy:
- banned/muted, slow mode, membership required, etc.
-
If accepted, the room broadcasts to all member inbox Subjects:
- for each
member.inbox: sendConnMsg.RoomEvent(msg)to that inbox
- for each
-
Each connection process receiving
RoomEvent(msg)forwards it to its WebSocket.
Client
│
│ send chat frame
▼
Connection Process
│
│ decode frame
│ build ChatMessage
│ (includes room_id)
│
│──────────────▶ Pipeline: Validation
│ (via Subject)
│
│──────────────▶ Pipeline: Safety Checks
│ (text/image logic)
│
│──────────────▶ Pipeline: Logging
│ (side-effect)
│
│──────────────▶ Pipeline: Routing
│
│ use cached room_control (cmd subject)
│
│──────────── Publish(msg) ────────────────▶
│ Room Process
│ - apply room policy
│ - membership required
│ - slow mode / bans
│
│ if accepted:
│ for each member inbox:
│ send RoomEvent(msg)
│
│◀──────────── RoomEvent(msg) ──────────────
│
│ forward to WebSocket
▼
Client
Messages go through a pipeline before reaching a room. Each pipeline node communicates via a Subject.
- Pipeline nodes are decoupled: a node publishes output to the next node’s subject.
- Multiple “side listeners” can subscribe to pipeline events (e.g., logging/metrics) without changing the core flow.
-
Each stage is a small process that:
- subscribes to an input subject
- processes messages
- either publishes to the next stage’s subject or drops/rejects
-
The pipeline runs inside the BEAM VM and is ephemeral (no durability/replay requirements at this level).
Example stages (exact set can evolve):
-
Validation
- schema/type validation, size limits, required fields (
room_id, etc.)
- schema/type validation, size limits, required fields (
-
Safety Checks
- different logic depending on
kind(text vs image)
- different logic depending on
-
Logging
- optional side-effect logging of accepted/rejected messages
-
Routing
- final stage that sends
Publishto the room command subject
- final stage that sends
- On failure at any stage, the message is rejected and the connection process is notified (e.g., “invalid message”, “unsafe content”, “rate limited”).
- Room-level policy checks still exist as a final gate even if most checks happen in the pipeline.
Rooms do not use Subjects as “topics” for membership/broadcast fan-out.
- Membership is tracked explicitly in the room process via member inbox Subjects.
- This allows strong control for policies, capacity, and cleanup.
- Broadcasting is implemented by iterating members and sending
RoomEventto each member inbox Subject.
-
Connection process
- owns the socket
- owns the pipeline entry
- caches
room_control - exposes
conn_inboxsubject for room events
-
Pipeline
- uses Subjects for decoupled stages
- augments/filters messages before they reach rooms
-
Room process
- is authoritative
- owns membership and policies
- broadcasts by sending to member inbox Subjects
-
Subjects
- used as typed inboxes/addresses (everywhere)
- not relied upon for room membership introspection or automatic fan-out
- React app with React Compiler enabled.
- Tailwind CSS for styling.
- DaisyUI as the component/theme layer on top of Tailwind.
src/api/types.tsmirrors backenddomain/request.gleamanddomain/response.gleamwire types;src/api/ws.tsprovides transport helpers.
- Integration tests run via
node:test+tsxfromfrontend. - Backend must be running on
http://localhost:8080for integration tests. - Command:
pnpm test:integration. - Tests live under
frontend/tests/integration/with helpers infrontend/tests/helpers/. - UI/E2E tests are not used; integration coverage is exercised through the frontend test runner.