🏗️ Architecture Deep Dive
Comprehensive guide to XHub Chat's internal architecture, design patterns, and implementation details.
Overview
XHub Chat follows a layered architecture with clear separation of concerns:
┌─────────────────────────────────────────────────────────┐
│ React Layer │
│ (Hooks, Context, Components) │
└────────────────────┬────────────────────────────────────┘
│
┌────────────────────▼────────────────────────────────────┐
│ Core Client │
│ (XHubChatClient API) │
└─────┬──────────────┬──────────────┬─────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Sync │ │ Store │ │ HTTP │
│ Engine │ │ Layer │ │ API │
└──────────┘ └──────────┘ └──────────┘
│ │ │
└──────────────┴──────────────┘
│
▼
┌──────────────┐
│ WebSocket │
│ Connection │
└──────────────┘
Core Components
1. XHubChatClient
The main entry point that orchestrates all functionality.
Responsibilities:
- Initialize and manage all subsystems
- Provide unified API for consumers
- Handle lifecycle (startup/shutdown)
- Emit events for state changes
Key Code:
export class XHubChatClient extends TypedEventEmitter<Events, EventHandlerMap> {
private store: IStore;
private syncApi: SyncApi;
private http: XHubChatHttpApi;
private syncState: SyncState = SyncState.Stopped;
constructor(opts: ICreateClientOpts) {
super();
// Initialize store
this.store = this.createStore(opts.store);
// Initialize HTTP API
this.http = new XHubChatHttpApi(opts);
// Initialize sync engine
this.syncApi = new SyncApi(this, opts);
}
public async startClient(opts?: IStartClientOpts): Promise<void> {
// 1. Start store
await this.store.startup();
// 2. Load cached data
await this.restoreFromCache();
// 3. Start syncing
await this.syncApi.sync(opts);
this.emit(ClientEvent.Sync, SyncState.Prepared);
}
}
2. Store Architecture
The store layer handles all data persistence with multiple implementations.
Store Hierarchy
┌────────────────────────────────────────────────────────────┐
│ IStore Interface │
│ (Contract: getRoom, storeRoom, save, etc.) │
└────────────────────┬───────────────────────────────────────┘
│
┌────────────┴────────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ StubStore │ │ MemoryStore │
│ (No-op) │ │ (In-memory) │
└──────────────┘ └────────┬─────┘
│ Extends
│
▼
┌──────────────────┐
│ IndexedDBStore │
│ (Persistent) │
└────────┬─────────┘
│
┌────────────────────┴────────────────┐
│ │
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ LocalIndexedDB │ │ RemoteIndexedDB │
│ Backend │ │ Backend │
│ (Main Thread) │ │ (Web Worker) │
└─────────────────────┘ └────────┬────────────┘
│
│ Uses
│
▼
┌─────────────────────┐
│ IndexedDBStore │
│ Worker │
│ (Wraps Local) │
└─────────────────────┘
IStore Interface
export interface IStore {
// Sync management
getSyncToken(): string | null;
setSyncToken(token: string): void;
// Room operations
storeRoom(room: Room): void;
getRoom(roomId: string): Room | null;
getRooms(): Room[];
removeRoom(roomId: string): void;
// Event operations
storeEvents(room: Room, events: Event[], token: string | null, toStart: boolean): void;
scrollback(room: Room, limit: number): Event[];
// User operations
storeUser(user: User): void;
getUser(userId: string): User | null;
// Lifecycle
startup(): Promise<void>;
save(force?: boolean): Promise<void>;
deleteAllData(): Promise<void>;
destroy(): Promise<void>;
}
MemoryStore Implementation
export class MemoryStore implements IStore {
private rooms: Record<string, Room> = {};
private users: Record<string, User> = {};
private syncToken: string | null = null;
public accountData: Map<string, Event> = new Map();
public storeRoom(room: Room): void {
this.rooms[room.roomId] = room;
}
public getRoom(roomId: string): Room | null {
return this.rooms[roomId] || null;
}
public getRooms(): Room[] {
return Object.values(this.rooms);
}
// In-memory only, no persistence
public async save(): Promise<void> {
// No-op for memory store
}
}
IndexedDBStore Implementation
Extends MemoryStore and adds IndexedDB persistence:
export class IndexedDBStore extends MemoryStore {
private backend: IIndexedDBBackend;
private syncAccumulator = new SyncAccumulator();
constructor(opts: IndexedDBStoreOpts) {
super(opts);
// Choose backend based on worker support
if (opts.workerApi && isWorkerSupported()) {
this.backend = new RemoteIndexedDBStoreBackend(
opts.workerFactory,
opts.dbName
);
} else {
this.backend = new LocalIndexedDBStoreBackend(opts.dbName);
}
}
public async startup(): Promise<void> {
// 1. Connect to IndexedDB
await this.backend.connect();
// 2. Load cached data
const savedSync = await this.backend.getSavedSync();
if (savedSync) {
this.syncToken = savedSync.nextBatch;
// Restore rooms
for (const roomData of savedSync.roomsData || []) {
const room = new Room(roomData.roomId, this.client, this.client.getUserId());
room.deserialize(roomData);
this.storeRoom(room);
}
}
}
public async save(force?: boolean): Promise<void> {
if (!force && !this.syncAccumulator.hasPendingData()) {
return;
}
const data = this.syncAccumulator.getJSON();
await this.backend.syncToDatabase(data);
this.syncAccumulator.clear();
}
}
3. Sync Engine
Handles synchronization with the server using sliding sync protocol.
Sliding Sync Architecture
┌─────────────────────────────────────────────────────────────┐
│ SyncApi │
│ - Manages sync loop │
│ - Handles reconnection │
│ - Processes sync responses │
└────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SlidingSyncSdk │
│ - Sliding sync implementation │
│ - List management │
│ - Room subscriptions │
└────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ HTTP/WebSocket │
│ - Long-polling or WebSocket │
│ - Automatic retries │
└─────────────────────────────────────────────────────────────┘
Sync Flow
export class SyncApi {
private client: XHubChatClient;
private running = false;
public async sync(opts?: IStartClientOpts): Promise<void> {
this.running = true;
while (this.running) {
try {
// 1. Send sync request
const response = await this.client.http.authedRequest(
Method.Get,
'/sync',
{
timeout: 30000,
filter: opts?.filter,
since: this.client.store.getSyncToken(),
}
);
// 2. Process response
await this.processSyncResponse(response);
// 3. Update sync token
this.client.store.setSyncToken(response.next_batch);
// 4. Save to database
await this.client.store.save();
// 5. Emit sync event
this.client.emit(ClientEvent.Sync, SyncState.Syncing);
} catch (error) {
logger.error('Sync error:', error);
await this.handleSyncError(error);
}
}
}
private async processSyncResponse(data: ISyncResponse): Promise<void> {
// Process rooms
for (const [roomId, roomData] of Object.entries(data.rooms.join)) {
let room = this.client.getRoom(roomId);
if (!room) {
room = new Room(roomId, this.client, this.client.getUserId());
this.client.store.storeRoom(room);
}
// Add timeline events
const events = roomData.timeline?.events.map(e => new Event(e)) || [];
room.addLiveEvents(events);
// Update room state
if (roomData.state) {
room.currentState.setStateEvents(
roomData.state.events.map(e => new Event(e))
);
}
this.client.emit(ClientEvent.Room, room);
}
// Process account data
if (data.account_data) {
const events = data.account_data.events.map(e => new Event(e));
this.client.store.storeAccountDataEvents(events);
}
}
}
4. Event System
Type-safe event emitter for all client events.
export enum ClientEvent {
Sync = "sync",
Room = "Room",
Event = "Event",
RoomTimeline = "Room.timeline",
RoomState = "RoomState.events",
RoomMember = "RoomMember.membership",
DeleteRoom = "deleteRoom",
}
export interface Events {
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null) => void;
[ClientEvent.Room]: (room: Room) => void;
[ClientEvent.Event]: (event: Event) => void;
[ClientEvent.RoomTimeline]: (
event: Event,
room: Room | undefined,
toStartOfTimeline: boolean
) => void;
}
// Usage
client.on(ClientEvent.Room, (room) => {
console.log('New room:', room.name);
});
5. Room Model
Represents a chat room with state, timeline, and members.
export class Room extends TypedEventEmitter<Events, EventHandlerMap> {
public roomId: string;
public name: string;
public currentState: RoomState;
private timeline: Event[] = [];
private members: Map<string, RoomMember> = new Map();
constructor(roomId: string, client: XHubChatClient, userId: string) {
super();
this.roomId = roomId;
this.currentState = new RoomState(roomId, client, userId);
}
public addLiveEvents(events: Event[]): void {
for (const event of events) {
this.timeline.push(event);
// Update room state if state event
if (event.isState()) {
this.currentState.setStateEvents([event]);
}
this.emit(RoomEvent.Timeline, event, this, false);
}
}
public getLiveTimeline(): Event[] {
return this.timeline;
}
public getMember(userId: string): RoomMember | null {
return this.members.get(userId) || null;
}
}
Data Flow
Sending a Message
┌──────────┐ 1. sendTextMessage() ┌──────────┐
│ React │ ──────────────────────────────>│ Client │
│ Hook │ └────┬─────┘
└──────────┘ │
│ 2. Create Event
│
▼
┌──────────┐
│ Room │
└────┬─────┘
│
│ 3. Optimistic Update
│
▼
┌──────────┐
│ Store │
└────┬─────┘
│
│ 4. HTTP POST
│
▼
┌──────────┐
│ Server │
└────┬─────┘
│
│ 5. Sync Response
│
▼
┌──────────┐
│ Client │
└────┬─────┘
│
│ 6. Update Event ID
│
▼
┌──────────┐
│ Room │
└──────────┘
Receiving a Message
┌──────────┐ 1. Sync Response ┌──────────┐
│ Server │ ──────────────────────────────>│ Client │
└──────────┘ └────┬─────┘
│
│ 2. Process Events
│
▼
┌──────────┐
│ Room │
└────┬─────┘
│
│ 3. Add to Timeline
│
▼
┌──────────┐
│ Store │
└────┬─────┘
│
│ 4. Emit Event
│
▼
┌──────────┐
│ React │
│ Hook │
└──────────┘
Performance Optimizations
1. Web Worker for IndexedDB
Move IndexedDB operations off the main thread:
// Main thread
const backend = new RemoteIndexedDBStoreBackend(
() => new Worker('/indexeddb.worker.js'),
'xhub-chat-db'
);
// Worker thread
class IndexedDBStoreWorker {
private backend: LocalIndexedDBStoreBackend;
constructor() {
this.backend = new LocalIndexedDBStoreBackend('xhub-chat-db');
self.addEventListener('message', this.onMessage.bind(this));
}
private async onMessage(event: MessageEvent): Promise<void> {
const { command, data, seq } = event.data;
try {
const result = await this.backend[command](data);
self.postMessage({ seq, result });
} catch (error) {
self.postMessage({ seq, error: error.message });
}
}
}
2. Sync Accumulator
Batch database writes to reduce I/O:
export class SyncAccumulator {
private accountData: Event[] = [];
private roomsData: Map<string, RoomData> = new Map();
public accumulate(syncResponse: ISyncResponse): void {
// Accumulate changes
if (syncResponse.account_data) {
this.accountData.push(...syncResponse.account_data.events);
}
// Don't write yet, wait for save() call
}
public hasPendingData(): boolean {
return this.accountData.length > 0 || this.roomsData.size > 0;
}
public getJSON(): any {
return {
accountData: this.accountData,
roomsData: Array.from(this.roomsData.values()),
};
}
public clear(): void {
this.accountData = [];
this.roomsData.clear();
}
}
3. Event Pagination
Load events on-demand to reduce memory usage:
export class Room {
private timeline: Event[] = [];
private timelineState = {
pagination: {
backwards: true, // Can load older events
forwards: false, // At live edge
},
};
public async paginate(direction: 'b' | 'f', limit = 30): Promise<void> {
if (direction === 'b' && !this.timelineState.pagination.backwards) {
return; // Already at start
}
const token = this.getLiveTimeline().getPaginationToken(direction);
const response = await this.client.http.authedRequest(
Method.Get,
`/rooms/${this.roomId}/messages`,
{
from: token,
dir: direction,
limit,
}
);
const events = response.chunk.map((e: any) => new Event(e));
if (direction === 'b') {
this.timeline.unshift(...events);
} else {
this.timeline.push(...events);
}
// Update pagination state
if (!response.end) {
this.timelineState.pagination[direction === 'b' ? 'backwards' : 'forwards'] = false;
}
}
}
Best Practices
1. Use TypeScript Strictly
// Bad
const room: any = client.getRoom(roomId);
// Good
const room: Room | null = client.getRoom(roomId);
if (room) {
const events: Event[] = room.getLiveTimeline().getEvents();
}
2. Handle Errors Gracefully
try {
await client.sendTextMessage(roomId, 'Hello');
} catch (error) {
if (error instanceof MatrixError) {
if (error.errcode === 'M_FORBIDDEN') {
// Handle permission error
}
}
}
3. Clean Up Event Listeners
useEffect(() => {
const onRoom = (room: Room) => {
console.log('New room:', room.name);
};
client.on(ClientEvent.Room, onRoom);
return () => {
client.off(ClientEvent.Room, onRoom);
};
}, [client]);
4. Use Memoization
const events = useMemo(() => {
return room?.getLiveTimeline().getEvents() || [];
}, [room, room?.timeline.length]);
Testing Architecture
XHub Chat uses Jest for unit testing with extensive mocks:
describe('Room', () => {
let client: MockClient;
let room: Room;
beforeEach(() => {
client = new MockClient();
room = new Room('!room:server', client, '@user:server');
});
it('should add live events', () => {
const event = new Event({
type: 'm.room.message',
content: { body: 'Hello' },
});
room.addLiveEvents([event]);
expect(room.getLiveTimeline().getEvents()).toContain(event);
});
});
Next Steps
- 📚 Core Concepts - High-level overview
- 🔧 Lifecycle - Client lifecycle management
- 💡 API Reference - Full API documentation