Dialogue Architecture
This document describes the internal architecture, design decisions, and component interactions of the Dialogue real-time communication library.
1. Overview
Dialogue is an event-based realtime communication library built on Socket.IO and Hono for Bun/Node environments. The architecture prioritizes simplicity, type safety, and predictable behavior over flexibility.
1.1 Core Philosophy
- Config-first: All rooms and events defined upfront in one file
- Event-centric: Events are first-class citizens, not just message payloads
- Bounded rooms: Optional
maxSize for predictable scaling
- Same mental model: Frontend and backend share similar patterns
- Extensible: Designed for future SSE, Web Push, and FCM channels
1.2 Technology Stack
- Bun: JavaScript runtime and bundler
- Socket.IO: WebSocket abstraction with fallbacks
- Hono: Lightweight HTTP framework
- Zod: Runtime schema validation
- slang-ts: Result pattern utilities (
Ok, Err, Result)
2. System Architecture
2.1 High-Level Component Diagram
+------------------+ WebSocket +------------------+
| | <----------------> | |
| DialogueClient | Socket.IO | Dialogue |
| (Frontend) | | (Backend) |
| | | |
+------------------+ +------------------+
| |
v v
+------------------+ +------------------+
| RoomContext | | RoomManager |
| (per room) | | (coordinator) |
+------------------+ +------------------+
|
+-----------+-----------+
| | |
v v v
+-------+ +-------+ +-------+
| Room | | Room | | Room |
| chat | | orders| | ... |
+-------+ +-------+ +-------+
2.2 Component Responsibilities
3. Backend Architecture
3.1 Module Structure
dialogue/
types.ts # Type definitions (interfaces, no implementation)
define-event.ts # Event definition factory with Zod validation
room.ts # Room creation and room manager
client-handler.ts # Connected client wrapper
server.ts # Socket.IO + Hono + Bun server setup
create-dialogue.ts # Main factory function
index.ts # Barrel exports
3.2 Initialization Flow
When createDialogue(config) is called:
1. createDialogue(config)
|
+--> Create or use existing Hono app
|
+--> setupServer(app, config)
|
+--> Create Socket.IO server
|
+--> Create BunEngine adapter
|
+--> io.bind(engine)
|
+--> createRoomManager(io)
| |
| +--> For each room in config:
| roomManager.register(id, config)
|
+--> Set up connection handler
| |
| +--> io.on("connection", ...)
|
+--> Return { io, roomManager, start, stop }
|
+--> Return Dialogue instance
3.3 Room Manager
The RoomManager is the central coordinator for all rooms. It maintains two parallel maps:
const rooms = new Map<string, Room>();
const roomParticipants = new Map<string, Map<string, ConnectedClient>>();
Why two maps?
The Room instance is immutable after creation. Participant tracking is handled separately in roomParticipants to allow the room manager to enforce capacity limits across all operations.
3.4 Event Flow (Server-Side Trigger)
When dialogue.trigger(roomId, event, data) is called:
1. dialogue.trigger(roomId, event, data)
|
+--> roomManager.get(roomId)
|
+--> room.trigger(event, data, from)
|
+--> isEventAllowed(event.name, config.events)
|
+--> validateEventData(eventDef, data) [Zod validation]
|
+--> Create EventMessage envelope
| {
| event: "message",
| roomId: "chat",
| data: { text: "Hello" },
| from: "user-123",
| timestamp: 1707750000000
| }
|
+--> io.to(roomId).emit("dialogue:event", message)
|
+--> Call all registered event handlers
3.5 Event Flow (Client-Triggered)
When a client triggers an event via WebSocket:
1. Client emits "dialogue:trigger" { roomId, event, data }
|
+--> Server validates roomId and event name
|
+--> roomManager.get(roomId)
|
+--> Check if event is allowed in room
|
+--> room.trigger(eventDef, data, client.userId)
|
+--> [Same flow as server-side trigger]
4. Client Architecture
4.1 Module Structure
client/
types.ts # Client-side type definitions
dialogue-client.ts # Main DialogueClient class
room-context.ts # RoomContext factory
index.ts # Barrel exports
4.2 Connection Flow
1. createDialogueClient({ url, auth })
|
+--> Create socket.io-client instance
|
+--> Connect with auth in handshake
|
+--> Wait for "dialogue:connected" event
|
+--> Extract userId from response
|
+--> Set connected = true
4.3 Room Join Flow
1. client.join("chat")
|
+--> socket.emit("dialogue:join", { roomId: "chat" })
|
+--> Wait for "dialogue:joined" event
|
+--> createRoomContext(socket, roomId, roomName)
|
+--> Return RoomContext
4.4 RoomContext Event Handling
The RoomContext listens for dialogue:event messages and filters by room:
socket.on("dialogue:event", (msg) => {
if (msg.roomId !== roomId) return;
// Call specific event handlers
const handlers = eventHandlers.get(msg.event);
if (handlers) {
handlers.forEach(h => h(msg));
}
// Call wildcard handlers
anyHandlers.forEach(h => h(msg.event, msg));
});
5. Wire Protocol
5.1 Socket.IO Events
All events are prefixed with dialogue: to avoid conflicts.
Client to Server:
Server to Client:
5.2 EventMessage Envelope
All events are wrapped in a consistent envelope:
interface EventMessage<T> {
event: string; // Event name (e.g., "message")
roomId: string; // Room ID (e.g., "chat")
data: T; // Event payload
from: string; // Sender's userId
timestamp: number; // Unix timestamp in milliseconds
}
6. Design Decisions
6.1 Config-First with Dynamic Creation
Dialogue is designed with a config-first philosophy while supporting dynamic room creation for flexibility.
Recommended Approach (80/20 Rule)
80% Predefined Rooms (config-first):
const dialogue = createDialogue({
rooms: [
{ id: 'lobby', name: 'Main Lobby', events: [...] },
{ id: 'notifications', name: 'Notifications', events: [...] },
{ id: 'support', name: 'Support Chat', events: [...] }
]
});
Benefits:
- Type safety and validation at startup
- Clear system architecture
- Predictable resource usage
- Better documentation
20% Dynamic Rooms (runtime creation):
// User creates a game room
dialogue.createRoom({
id: `game-${gameId}`,
name: `Game ${gameId}`,
events: gameEvents
});
// Clean up when done
dialogue.deleteRoom(`game-${gameId}`);
Use for:
- User-generated content (custom game rooms, DMs)
- Temporary sessions (video calls, screen shares)
- Per-entity rooms (document editing, ticket threads)
Hybrid Example
// Predefined: System-wide rooms
const systemRooms = [
{ id: 'global-chat', name: 'Chat', events: [chatEvent] },
{ id: 'notifications', name: 'Notifications', events: [notifEvent] }
];
const dialogue = createDialogue({ rooms: systemRooms });
// Dynamic: User-specific rooms
app.post('/games', async (c) => {
const gameId = nanoid();
dialogue.createRoom({
id: `game-${gameId}`,
name: 'Game Session',
events: [moveEvent, scoreEvent],
maxSize: 4
});
return c.json({ gameId });
});
When to Use Each
Key principle: If you know the room type at build time, define it in config. If it's created by user actions, create it dynamically.
6.2 Why Event-Centric?
Problem: Generic "message" events require runtime type checking and are error-prone.
Solution: First-class event definitions with optional Zod schemas:
const Message = defineEvent("message", {
schema: z.object({
text: z.string(),
senderId: z.string()
})
});
Benefits:
- Compile-time type inference
- Runtime validation
- Self-documenting code
- IDE autocomplete
6.3 Why Bounded Rooms?
Problem: Unbounded rooms can grow indefinitely, causing memory issues and performance degradation.
Solution: Optional maxSize configuration:
rooms: {
chat: {
name: "Support Chat",
maxSize: 50, // Enforced at join time
events: [Message]
}
}
6.4 Why Separate RoomManager?
Problem: Rooms need to track participants, but participant state must be consistent across the system.
Solution: The RoomManager owns participant state in a separate map, ensuring:
- Consistent capacity enforcement
- Single source of truth for participants
- Clean separation between room definition and runtime state
6.5 Why Socket.IO Over Raw WebSockets?
Advantages:
- Automatic reconnection
- Fallback transports (polling)
- Built-in room abstraction
- Mature, well-tested library
- Easy integration with existing infrastructure
Trade-offs:
- Larger bundle size
- Additional protocol overhead
- Less control over low-level behavior
7. Data Flow Diagrams
7.1 Client Sends Message
DialogueClient Socket.IO Dialogue Room
| | | |
| trigger("message", | | |
| { text: "Hi" }) | | |
|-------------------->| | |
| | dialogue:trigger | |
| |----------------->| |
| | | room.trigger() |
| | |--------------->|
| | | |
| | | validate() |
| | |<---------------|
| | | |
| | io.to(roomId) | |
| | .emit() | |
| |<-----------------| |
| dialogue:event | | |
|<--------------------| | |
| | | |
7.2 Server Broadcasts Event
API Route Dialogue Room Clients
| | | |
| trigger("orders", | | |
| OrderUpdated, | | |
| { status: ... }) | | |
|------------------->| | |
| | room.trigger() | |
| |--------------->| |
| | | |
| | | validate() |
| | | |
| | io.to(roomId) | |
| | .emit() | |
| |--------------->|--------------->|
| | | |
8. Security Considerations
8.1 Authentication
Authentication is handled via Socket.IO handshake:
const client = createDialogueClient({
url: "ws://localhost:3000",
auth: { token: "user-jwt-token" }
});
The server extracts user identity in extractUserFromSocket():
export function extractUserFromSocket(socket: Socket) {
const auth = socket.handshake.auth;
// Extract userId from token or auth payload
// Return { userId, meta }
}
8.2 Event Validation
All events with Zod schemas are validated before broadcasting. Validation returns a Result<T, string> using the slang-ts pattern:
const validation = validateEventData(eventDef, data);
if (validation.isErr) {
// Reject invalid data - validation.error contains the error message
return;
}
// validation.value contains the validated data
8.3 Room Access Control
Room access can be controlled in the onConnect handler:
onConnect: (client) => {
if (client.meta.role === "admin") {
client.join("admin-room");
}
}
9. Scalability Considerations
9.1 Current Limitations
- Single server instance only
- In-memory participant tracking
- No persistence layer
9.2 Future Scaling Options
Horizontal Scaling: Add Redis adapter for multi-instance:
// Future API (not implemented)
import { createRedisAdapter } from "dialogue/adapters/redis";
const dialogue = createDialogue({
adapter: createRedisAdapter({ host: "localhost", port: 6379 }),
// ...
});
Persistence Layer: Add event persistence interface:
// Future API (not implemented)
const dialogue = createDialogue({
persistence: {
saveEvent: (msg) => db.events.insert(msg),
loadEvents: (roomId, limit) => db.events.find({ roomId }).limit(limit)
}
});
10.1 Memory Usage
- Each connected client: ~1-2 KB (socket + metadata)
- Each room: ~200 bytes + participants
- Event handlers: ~100 bytes per handler
10.2 Message Latency
- Local (same machine): < 1ms
- Network: RTT + ~1-2ms processing
10.3 Throughput
- Depends on Bun/Node event loop
- Socket.IO overhead: ~5-10% vs raw WebSockets
- Zod validation: ~0.1ms per event (for typical payloads)
11. Extension Points
11.1 Custom Authentication
Override extractUserFromSocket() for custom auth strategies:
// Verify JWT, check database, etc.
export function extractUserFromSocket(socket: Socket) {
const token = socket.handshake.auth.token;
const user = verifyJWT(token);
return { userId: user.id, meta: { role: user.role } };
}
11.2 Event Middleware (Future)
Planned middleware pipeline for events:
// Future API (not implemented)
dialogue.use("chat", (msg, next) => {
// Rate limiting, content filtering, etc.
if (isSpam(msg)) return;
next();
});
11.3 Additional Channels (Future)
Planned support for alternative delivery channels:
- SSE: Server-sent events for one-way server to client
- Web Push: Push notifications via FCM/APNS
- HTTP Polling: For environments without WebSocket support
This specification reflects the current implementation and is subject to evolution. Contributions and feedback are welcome.