9.1 KiB
9.1 KiB
PRD: TUI Session Manager
Module: tui-sm/ | Namespace: ajet.chat.tui-sm.*
Status: v1 | Last updated: 2026-02-17
1. Overview
The TUI Session Manager serves terminal clients via SSE. It holds live connections from TUI clients, subscribes to NATS for real-time events, and calls the API for data. It acts as an intermediary — translating NATS events into structured SSE events that the TUI client renders.
Unlike Web SM (which sends HTML fragments), TUI SM sends JSON-structured events over SSE. The TUI client handles all rendering.
2. Architecture
TUI Client ←─ SSE (JSON events) ──→ TUI SM ──→ NATS (subscribe)
←─ HTTP POST (signals) ──→ ──→ API (HTTP, data)
3. SSE Event Protocol
3.1 Connection
Endpoint: GET /tui/sse/events
Headers:
Accept: text/event-streamX-User-Id,X-Trace-Id(injected by Auth GW)
Query params:
community_id=<uuid>— active community (optional, can start with DMs)last_event_id=<id>— for reconnection replay
3.2 SSE Event Format
event: <event-type>
id: <sequential-id>
data: <json-payload>
3.3 Event Types
| Event Type | Payload | Description |
|---|---|---|
init |
{communities, channels, dms, user} |
Initial state on connect |
message.new |
{message} |
New message in subscribed channel |
message.edit |
{message} |
Message edited |
message.delete |
{message_id, channel_id} |
Message deleted |
reaction.add |
{message_id, emoji, user_id} |
Reaction added |
reaction.remove |
{message_id, emoji, user_id} |
Reaction removed |
typing.start |
{channel_id, user_id, username} |
User started typing |
typing.stop |
{channel_id, user_id} |
User stopped typing |
presence.update |
{user_id, status} |
Online/offline change |
channel.new |
{channel} |
New channel in community |
channel.update |
{channel} |
Channel updated (topic, name) |
channel.delete |
{channel_id} |
Channel deleted |
member.join |
{channel_id, user} |
User joined channel |
member.leave |
{channel_id, user_id} |
User left channel |
notification |
{notification} |
New notification for user |
unread.update |
{channel_id, count, mentions} |
Unread count changed |
ping |
{} |
Keepalive (every 30s) |
3.4 Init Event
Sent immediately after SSE connection established:
{
"communities": [
{"id": "uuid", "name": "My Team", "slug": "my-team", "role": "owner"}
],
"channels": {
"community-uuid": [
{"id": "uuid", "name": "general", "type": "text", "category": "General",
"unread_count": 3, "mention_count": 1}
]
},
"dms": [
{"id": "uuid", "type": "dm", "members": [...], "unread_count": 0}
],
"user": {"id": "uuid", "username": "alice", "display_name": "Alice"}
}
4. Client → Server Signals (HTTP POST)
| Action | Endpoint | Payload |
|---|---|---|
| Send message | POST /tui/messages |
{channel_id, body_md, parent_id?} |
| Edit message | POST /tui/messages/:id/edit |
{body_md} |
| Delete message | POST /tui/messages/:id/delete |
— |
| Add reaction | POST /tui/reactions |
{message_id, emoji} |
| Remove reaction | POST /tui/reactions/remove |
{message_id, emoji} |
| Switch channel | POST /tui/navigate |
{channel_id, community_id?} |
| Mark read | POST /tui/read |
{channel_id, message_id} |
| Typing | POST /tui/typing |
{channel_id} |
| Heartbeat | POST /tui/heartbeat |
— |
| Fetch messages | GET /tui/messages |
?channel_id=<uuid>&before=<uuid>&limit=50 |
| Search | GET /tui/search |
?q=<query>&type=<type> |
| Slash command | POST /tui/command |
{command, channel_id, community_id} |
All responses are JSON.
5. Connection Tracking
{user-id {:sse-channel <http-kit-channel>
:active-community uuid-or-nil
:active-channel uuid-or-nil
:nats-subs [sub-handles...]
:last-event-id int
:connected-at instant}}
- On connect: send
initevent, subscribe to user's NATS subjects - On disconnect: unsubscribe from NATS, clean up state
- On community switch: update NATS subscriptions
- Keepalive pings every 30 seconds to detect dead connections
6. Presence Batching
TUI SM buffers presence events to avoid flooding clients:
- Collect heartbeat events over 60-second windows
- On flush: diff with previous state, send only changes
- Typing indicators sent immediately (latency-sensitive)
7. Service Configuration
7.1 Config Shape
{:server {:host "0.0.0.0" :port 3003}
:api {:base-url "http://localhost:3001"}
:nats {:url "nats://localhost:4222"
:stream-name "ajet-events"}
:session {:max-connections 5000 ;; max concurrent SSE connections
:ping-interval-sec 30}
:presence {:batch-interval-sec 60}}
7.2 Startup / Shutdown Sequence
Startup:
1. Load config
2. Connect to NATS
3. Initialize connection tracker (atom)
4. Start http-kit server
5. Start ping scheduler (30s interval)
6. Log "TUI SM started on port {port}"
Shutdown (graceful):
1. Stop accepting new SSE connections
2. Send close event to all connected TUI clients
3. Stop ping scheduler
4. Unsubscribe all NATS subscriptions
5. Close NATS connection
6. Stop http-kit server
7. Log "TUI SM stopped"
7.3 Health Check
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /tui/health |
None | Service health |
{"status": "ok", "connections": 23, "checks": {"api": "ok", "nats": "ok"}}
7.4 Error Handling
| Scenario | Behavior |
|---|---|
| API unavailable | Client signals (POST) return 502 JSON error. SSE stays open. |
| NATS unavailable | SSE stays open, no real-time events. Events resume when NATS reconnects. Status event sent to client. |
| Client sends invalid JSON | Return 400 with error description |
| Client sends to unauthorized channel | Return 403 |
| SSE write fails (dead connection) | Clean up connection state, unsubscribe NATS |
| Max connections reached | Return 503 {"error": "max connections reached, retry later"} |
7.5 Backpressure
- If a client's SSE buffer exceeds 1000 events, disconnect the client (forces reconnect with replay)
- Events are dropped (not queued) if write buffer is full — JetStream replay handles recovery
- Connection tracking atom is bounded by
max-connectionsconfig
8. Test Cases
8.1 SSE Connection
| ID | Test | Description |
|---|---|---|
| TSM-T1 | SSE connect | GET /tui/sse/events returns text/event-stream |
| TSM-T2 | Init event sent | First event is init with communities, channels, DMs, user |
| TSM-T3 | NATS subscribed on connect | Server subscribes to user's community subjects |
| TSM-T4 | Ping keepalive | Ping event sent every 30 seconds |
| TSM-T5 | Disconnect cleanup | SSE disconnect unsubscribes from NATS |
| TSM-T6 | Reconnect with last_event_id | Missed events replayed via JetStream |
8.2 Real-Time Events
| ID | Test | Description |
|---|---|---|
| TSM-T7 | New message event | Message from NATS → SSE message.new event to client |
| TSM-T8 | Message edit event | Edit from NATS → SSE message.edit event |
| TSM-T9 | Message delete event | Delete from NATS → SSE message.delete event |
| TSM-T10 | Reaction events | Add/remove from NATS → SSE reaction events |
| TSM-T11 | Typing indicator | Typing from NATS → SSE typing.start event |
| TSM-T12 | Typing auto-expire | typing.stop sent after 15 seconds of silence |
| TSM-T13 | Presence update | Batched presence diffs sent every 60 seconds |
| TSM-T14 | Channel events | New/update/delete channel events forwarded |
| TSM-T15 | Notification event | User-targeted notification forwarded |
| TSM-T16 | Unread count update | New message in other channel → unread count SSE event |
8.3 Client Signals
| ID | Test | Description |
|---|---|---|
| TSM-T17 | Send message | POST /tui/messages → API call → success response |
| TSM-T18 | Edit message | POST /tui/messages/:id/edit → API call → success |
| TSM-T19 | Delete message | POST /tui/messages/:id/delete → API call → success |
| TSM-T20 | Switch channel | POST /tui/navigate → NATS sub update → returns channel messages |
| TSM-T21 | Fetch older messages | GET /tui/messages with before cursor → paginated results |
| TSM-T22 | Search | GET /tui/search → API search → JSON results |
| TSM-T23 | Slash command | POST /tui/command → API command → result |
| TSM-T24 | Heartbeat | POST /tui/heartbeat → proxied to API |
8.4 Edge Cases
| ID | Test | Description |
|---|---|---|
| TSM-T25 | Multiple TUI clients | Same user, two connections — both receive events |
| TSM-T26 | API unavailable | Returns 502, SSE stays open |
| TSM-T27 | NATS unavailable | SSE stays open, events resume when NATS recovers |
| TSM-T28 | Event filtering | Events for non-subscribed channels not forwarded |
| TSM-T29 | DM events | DM messages forwarded via chat.dm.{channel-id} subscription |
| TSM-T30 | Large init payload | Init event with many channels/DMs serializes correctly |