detached 78e1985

feat(sse): introduce modular SSE client infrastructure

- Add SSEClient: main client for managing SSE connections
- Add SSEEventEmitter: typed event handling system
- Add SSEManager: high-level connection management
- Add MessageQueue: reliable message queueing system
- Add config: centralized configuration and types
- Add comprehensive unit tests for all SSE modules
commit
78e1985255c20adef1e1edcd94284618ff6038e3
author
Kit · · 2025-12-15 10:21 UTC
parent
44acee2
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
7 files · +1508 -0
A src/lib/sse/MessageQueue.ts +227 -0
@@ -0,0 +1,227 @@
1+/**
2+ * Server-side Message Queue for SSE Catch-up
3+ *
4+ * Stores recent messages to allow clients to catch up after reconnection
5+ * using the Last-Event-ID mechanism.
6+ */
7+
8+import { SSE_CONFIG } from './config';
9+
10+/**
11+ * Queued message structure
12+ */
13+export interface QueuedMessage {
14+ /** Unique event ID for Last-Event-ID support */
15+ id: string;
16+ /** Event type (chatMessage, userStatusUpdate, etc.) */
17+ event: string;
18+ /** Event payload */
19+ data: unknown;
20+ /** Timestamp for TTL expiration */
21+ timestamp: number;
22+ /** Chat room ID for filtering */
23+ roomId: string;
24+}
25+
26+/**
27+ * Message Queue for SSE catch-up support
28+ *
29+ * Maintains a queue of recent messages per room to allow clients
30+ * to catch up after reconnection using Last-Event-ID.
31+ */
32+class MessageQueue {
33+ private queues: Map<string, QueuedMessage[]> = new Map();
34+ private readonly ttl: number;
35+ private readonly maxSize: number;
36+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
37+
38+ constructor(
39+ ttl: number = SSE_CONFIG.MESSAGE_QUEUE_TTL,
40+ maxSize: number = SSE_CONFIG.MESSAGE_QUEUE_MAX_SIZE
41+ ) {
42+ this.ttl = ttl;
43+ this.maxSize = maxSize;
44+
45+ // Start periodic cleanup
46+ this.startCleanup();
47+ console.log('[MessageQueue] Initialized:', { ttl, maxSize });
48+ }
49+
50+ /**
51+ * Add a message to the queue
52+ * @returns The generated event ID
53+ */
54+ enqueue(roomId: string, event: string, data: unknown): string {
55+ const id = crypto.randomUUID();
56+ const message: QueuedMessage = {
57+ id,
58+ event,
59+ data,
60+ timestamp: Date.now(),
61+ roomId
62+ };
63+
64+ let queue = this.queues.get(roomId);
65+ if (!queue) {
66+ queue = [];
67+ this.queues.set(roomId, queue);
68+ }
69+
70+ queue.push(message);
71+
72+ // Trim if over max size (keep newest)
73+ if (queue.length > this.maxSize) {
74+ const trimmed = queue.length - this.maxSize;
75+ this.queues.set(roomId, queue.slice(-this.maxSize));
76+ console.log('[MessageQueue] Queue trimmed:', { roomId, trimmed });
77+ }
78+
79+ return id;
80+ }
81+
82+ /**
83+ * Get messages since a given event ID
84+ * If the ID is not found, returns the most recent messages
85+ */
86+ getMessagesSince(roomId: string, lastEventId: string): QueuedMessage[] {
87+ const queue = this.queues.get(roomId);
88+ if (!queue || queue.length === 0) {
89+ return [];
90+ }
91+
92+ const lastIndex = queue.findIndex((m) => m.id === lastEventId);
93+
94+ if (lastIndex === -1) {
95+ // ID not found - return recent messages as fallback
96+ console.log('[MessageQueue] Last-Event-ID not found, returning recent:', {
97+ roomId,
98+ lastEventId,
99+ returning: Math.min(50, queue.length)
100+ });
101+ return queue.slice(-50);
102+ }
103+
104+ // Return messages after the last seen ID
105+ const missed = queue.slice(lastIndex + 1);
106+ if (missed.length > 0) {
107+ console.log('[MessageQueue] Catch-up messages:', {
108+ roomId,
109+ lastEventId,
110+ missedCount: missed.length
111+ });
112+ }
113+ return missed;
114+ }
115+
116+ /**
117+ * Get all messages for a room (for initial load)
118+ */
119+ getMessages(roomId: string, limit: number = 50): QueuedMessage[] {
120+ const queue = this.queues.get(roomId);
121+ if (!queue) {
122+ return [];
123+ }
124+ return queue.slice(-limit);
125+ }
126+
127+ /**
128+ * Get the latest event ID for a room
129+ */
130+ getLatestEventId(roomId: string): string | null {
131+ const queue = this.queues.get(roomId);
132+ if (!queue || queue.length === 0) {
133+ return null;
134+ }
135+ return queue[queue.length - 1].id;
136+ }
137+
138+ /**
139+ * Check if an event ID exists in the queue
140+ */
141+ hasEventId(roomId: string, eventId: string): boolean {
142+ const queue = this.queues.get(roomId);
143+ if (!queue) {
144+ return false;
145+ }
146+ return queue.some((m) => m.id === eventId);
147+ }
148+
149+ /**
150+ * Remove expired messages from all queues
151+ */
152+ cleanup(): number {
153+ const now = Date.now();
154+ let totalRemoved = 0;
155+
156+ for (const [roomId, queue] of this.queues.entries()) {
157+ const originalLength = queue.length;
158+ const filtered = queue.filter((m) => now - m.timestamp < this.ttl);
159+
160+ if (filtered.length !== originalLength) {
161+ totalRemoved += originalLength - filtered.length;
162+ if (filtered.length === 0) {
163+ this.queues.delete(roomId);
164+ } else {
165+ this.queues.set(roomId, filtered);
166+ }
167+ }
168+ }
169+
170+ if (totalRemoved > 0) {
171+ console.log('[MessageQueue] Cleanup completed:', {
172+ removed: totalRemoved,
173+ activeRooms: this.queues.size
174+ });
175+ }
176+
177+ return totalRemoved;
178+ }
179+
180+ /**
181+ * Start periodic cleanup interval
182+ */
183+ private startCleanup(): void {
184+ // Cleanup every minute
185+ this.cleanupInterval = setInterval(() => {
186+ this.cleanup();
187+ }, 60_000);
188+ }
189+
190+ /**
191+ * Stop cleanup interval (for shutdown)
192+ */
193+ stopCleanup(): void {
194+ if (this.cleanupInterval) {
195+ clearInterval(this.cleanupInterval);
196+ this.cleanupInterval = null;
197+ }
198+ }
199+
200+ /**
201+ * Clear all queues (for testing or reset)
202+ */
203+ clear(): void {
204+ this.queues.clear();
205+ console.log('[MessageQueue] All queues cleared');
206+ }
207+
208+ /**
209+ * Get statistics about the queue
210+ */
211+ getStats(): { rooms: number; totalMessages: number } {
212+ let totalMessages = 0;
213+ for (const queue of this.queues.values()) {
214+ totalMessages += queue.length;
215+ }
216+ return {
217+ rooms: this.queues.size,
218+ totalMessages
219+ };
220+ }
221+}
222+
223+// Singleton instance
224+export const messageQueue = new MessageQueue();
225+
226+// Export class for testing
227+export { MessageQueue };
@@ -0,0 +1,227 @@
1+/**
2+ * Server-side Message Queue for SSE Catch-up
3+ *
4+ * Stores recent messages to allow clients to catch up after reconnection
5+ * using the Last-Event-ID mechanism.
6+ */
7+
8+import { SSE_CONFIG } from './config';
9+
10+/**
11+ * Queued message structure
12+ */
13+export interface QueuedMessage {
14+ /** Unique event ID for Last-Event-ID support */
15+ id: string;
16+ /** Event type (chatMessage, userStatusUpdate, etc.) */
17+ event: string;
18+ /** Event payload */
19+ data: unknown;
20+ /** Timestamp for TTL expiration */
21+ timestamp: number;
22+ /** Chat room ID for filtering */
23+ roomId: string;
24+}
25+
26+/**
27+ * Message Queue for SSE catch-up support
28+ *
29+ * Maintains a queue of recent messages per room to allow clients
30+ * to catch up after reconnection using Last-Event-ID.
31+ */
32+class MessageQueue {
33+ private queues: Map<string, QueuedMessage[]> = new Map();
34+ private readonly ttl: number;
35+ private readonly maxSize: number;
36+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
37+
38+ constructor(
39+ ttl: number = SSE_CONFIG.MESSAGE_QUEUE_TTL,
40+ maxSize: number = SSE_CONFIG.MESSAGE_QUEUE_MAX_SIZE
41+ ) {
42+ this.ttl = ttl;
43+ this.maxSize = maxSize;
44+
45+ // Start periodic cleanup
46+ this.startCleanup();
47+ console.log('[MessageQueue] Initialized:', { ttl, maxSize });
48+ }
49+
50+ /**
51+ * Add a message to the queue
52+ * @returns The generated event ID
53+ */
54+ enqueue(roomId: string, event: string, data: unknown): string {
55+ const id = crypto.randomUUID();
56+ const message: QueuedMessage = {
57+ id,
58+ event,
59+ data,
60+ timestamp: Date.now(),
61+ roomId
62+ };
63+
64+ let queue = this.queues.get(roomId);
65+ if (!queue) {
66+ queue = [];
67+ this.queues.set(roomId, queue);
68+ }
69+
70+ queue.push(message);
71+
72+ // Trim if over max size (keep newest)
73+ if (queue.length > this.maxSize) {
74+ const trimmed = queue.length - this.maxSize;
75+ this.queues.set(roomId, queue.slice(-this.maxSize));
76+ console.log('[MessageQueue] Queue trimmed:', { roomId, trimmed });
77+ }
78+
79+ return id;
80+ }
81+
82+ /**
83+ * Get messages since a given event ID
84+ * If the ID is not found, returns the most recent messages
85+ */
86+ getMessagesSince(roomId: string, lastEventId: string): QueuedMessage[] {
87+ const queue = this.queues.get(roomId);
88+ if (!queue || queue.length === 0) {
89+ return [];
90+ }
91+
92+ const lastIndex = queue.findIndex((m) => m.id === lastEventId);
93+
94+ if (lastIndex === -1) {
95+ // ID not found - return recent messages as fallback
96+ console.log('[MessageQueue] Last-Event-ID not found, returning recent:', {
97+ roomId,
98+ lastEventId,
99+ returning: Math.min(50, queue.length)
100+ });
101+ return queue.slice(-50);
102+ }
103+
104+ // Return messages after the last seen ID
105+ const missed = queue.slice(lastIndex + 1);
106+ if (missed.length > 0) {
107+ console.log('[MessageQueue] Catch-up messages:', {
108+ roomId,
109+ lastEventId,
110+ missedCount: missed.length
111+ });
112+ }
113+ return missed;
114+ }
115+
116+ /**
117+ * Get all messages for a room (for initial load)
118+ */
119+ getMessages(roomId: string, limit: number = 50): QueuedMessage[] {
120+ const queue = this.queues.get(roomId);
121+ if (!queue) {
122+ return [];
123+ }
124+ return queue.slice(-limit);
125+ }
126+
127+ /**
128+ * Get the latest event ID for a room
129+ */
130+ getLatestEventId(roomId: string): string | null {
131+ const queue = this.queues.get(roomId);
132+ if (!queue || queue.length === 0) {
133+ return null;
134+ }
135+ return queue[queue.length - 1].id;
136+ }
137+
138+ /**
139+ * Check if an event ID exists in the queue
140+ */
141+ hasEventId(roomId: string, eventId: string): boolean {
142+ const queue = this.queues.get(roomId);
143+ if (!queue) {
144+ return false;
145+ }
146+ return queue.some((m) => m.id === eventId);
147+ }
148+
149+ /**
150+ * Remove expired messages from all queues
151+ */
152+ cleanup(): number {
153+ const now = Date.now();
154+ let totalRemoved = 0;
155+
156+ for (const [roomId, queue] of this.queues.entries()) {
157+ const originalLength = queue.length;
158+ const filtered = queue.filter((m) => now - m.timestamp < this.ttl);
159+
160+ if (filtered.length !== originalLength) {
161+ totalRemoved += originalLength - filtered.length;
162+ if (filtered.length === 0) {
163+ this.queues.delete(roomId);
164+ } else {
165+ this.queues.set(roomId, filtered);
166+ }
167+ }
168+ }
169+
170+ if (totalRemoved > 0) {
171+ console.log('[MessageQueue] Cleanup completed:', {
172+ removed: totalRemoved,
173+ activeRooms: this.queues.size
174+ });
175+ }
176+
177+ return totalRemoved;
178+ }
179+
180+ /**
181+ * Start periodic cleanup interval
182+ */
183+ private startCleanup(): void {
184+ // Cleanup every minute
185+ this.cleanupInterval = setInterval(() => {
186+ this.cleanup();
187+ }, 60_000);
188+ }
189+
190+ /**
191+ * Stop cleanup interval (for shutdown)
192+ */
193+ stopCleanup(): void {
194+ if (this.cleanupInterval) {
195+ clearInterval(this.cleanupInterval);
196+ this.cleanupInterval = null;
197+ }
198+ }
199+
200+ /**
201+ * Clear all queues (for testing or reset)
202+ */
203+ clear(): void {
204+ this.queues.clear();
205+ console.log('[MessageQueue] All queues cleared');
206+ }
207+
208+ /**
209+ * Get statistics about the queue
210+ */
211+ getStats(): { rooms: number; totalMessages: number } {
212+ let totalMessages = 0;
213+ for (const queue of this.queues.values()) {
214+ totalMessages += queue.length;
215+ }
216+ return {
217+ rooms: this.queues.size,
218+ totalMessages
219+ };
220+ }
221+}
222+
223+// Singleton instance
224+export const messageQueue = new MessageQueue();
225+
226+// Export class for testing
227+export { MessageQueue };
A src/lib/sse/SSEClient.svelte.ts +324 -0
@@ -0,0 +1,324 @@
1+/**
2+ * SSE Client - Client-side SSE connection manager
3+ *
4+ * Handles EventSource connections with:
5+ * - Unlimited reconnection with exponential backoff + jitter
6+ * - Heartbeat monitoring and acknowledgment
7+ * - Last-Event-ID tracking for catch-up
8+ * - Reactive state using Svelte 5 runes
9+ */
10+
11+import { SSE_CONFIG, type ConnectionStatus } from './config';
12+import type { SSEEventMap } from './SSEEventEmitter';
13+
14+/**
15+ * Configuration for SSE Client
16+ */
17+export interface SSEClientConfig {
18+ /** SSE endpoint URL */
19+ url: string;
20+ /** Callback for incoming messages */
21+ onMessage: <T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) => void;
22+ /** Callback for connection status changes */
23+ onStatusChange?: (status: ConnectionStatus) => void;
24+ /** Callback for errors */
25+ onError?: (error: string) => void;
26+}
27+
28+/**
29+ * SSE Client with Svelte 5 reactive state
30+ */
31+export class SSEClient {
32+ // Configuration
33+ private config: SSEClientConfig;
34+
35+ // Connection state
36+ private eventSource: EventSource | null = null;
37+ private _connectionId: string | null = null;
38+
39+ // Reactive state using Svelte 5 runes
40+ private _status = $state<ConnectionStatus>('disconnected');
41+ private _lastEventId = $state<string | null>(null);
42+ private _error = $state<string | null>(null);
43+ private _reconnectAttempts = $state(0);
44+
45+ // Timers
46+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
47+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
48+
49+ // Derived state
50+ public status = $derived(this._status);
51+ public lastEventId = $derived(this._lastEventId);
52+ public error = $derived(this._error);
53+ public reconnectAttempts = $derived(this._reconnectAttempts);
54+ public isConnected = $derived(this._status === 'connected');
55+ public isReconnecting = $derived(this._status === 'reconnecting');
56+
57+ constructor(config: SSEClientConfig) {
58+ this.config = config;
59+ console.debug('[SSEClient] Initialized');
60+ }
61+
62+ /**
63+ * Connect to the SSE endpoint
64+ */
65+ connect(): void {
66+ if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
67+ console.debug('[SSEClient] Already connected or connecting');
68+ return;
69+ }
70+
71+ this.setStatus('connecting');
72+ this._error = null;
73+
74+ try {
75+ // EventSource automatically sends Last-Event-ID header on reconnection
76+ this.eventSource = new EventSource(this.config.url, { withCredentials: true });
77+
78+ this.eventSource.onopen = () => {
79+ console.debug('[SSEClient] Connection established');
80+ this.setStatus('connected');
81+ this._reconnectAttempts = 0;
82+ this._error = null;
83+ this.startHeartbeatMonitor();
84+ };
85+
86+ this.eventSource.onerror = (event) => {
87+ console.debug('[SSEClient] Connection error:', event);
88+
89+ if (this.eventSource?.readyState === EventSource.CLOSED) {
90+ this.handleDisconnection();
91+ } else if (this.eventSource?.readyState === EventSource.CONNECTING) {
92+ this.setStatus('reconnecting');
93+ }
94+ };
95+
96+ // Set up event listeners
97+ this.setupEventListeners();
98+ } catch (error) {
99+ console.error('[SSEClient] Failed to create EventSource:', error);
100+ this._error = 'Failed to connect';
101+ this.handleDisconnection();
102+ }
103+ }
104+
105+ /**
106+ * Disconnect from SSE
107+ */
108+ disconnect(): void {
109+ console.debug('[SSEClient] Disconnecting');
110+ this.cleanup();
111+ this.setStatus('disconnected');
112+ this._connectionId = null;
113+ }
114+
115+ /**
116+ * Get the current connection ID
117+ */
118+ getConnectionId(): string | null {
119+ return this._connectionId;
120+ }
121+
122+ /**
123+ * Set up event listeners for SSE events
124+ */
125+ private setupEventListeners(): void {
126+ if (!this.eventSource) return;
127+
128+ // Track last event ID from all events
129+ const trackEventId = (event: MessageEvent) => {
130+ if (event.lastEventId) {
131+ this._lastEventId = event.lastEventId;
132+ }
133+ };
134+
135+ // Chat message events
136+ this.eventSource.addEventListener('chatMessage', (event: MessageEvent) => {
137+ trackEventId(event);
138+ try {
139+ const data = JSON.parse(event.data);
140+ this.config.onMessage('chatMessage', data);
141+ } catch (error) {
142+ console.error('[SSEClient] Error parsing chatMessage:', error);
143+ }
144+ });
145+
146+ // User status update events
147+ this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => {
148+ trackEventId(event);
149+ try {
150+ const data = JSON.parse(event.data);
151+ this.config.onMessage('userStatusUpdate', data);
152+ } catch (error) {
153+ console.error('[SSEClient] Error parsing userStatusUpdate:', error);
154+ }
155+ });
156+
157+ // Buddy list update events
158+ this.eventSource.addEventListener('buddyListUpdate', (event: MessageEvent) => {
159+ trackEventId(event);
160+ try {
161+ const data = JSON.parse(event.data);
162+ this.config.onMessage('buddyListUpdate', data);
163+ } catch (error) {
164+ console.error('[SSEClient] Error parsing buddyListUpdate:', error);
165+ }
166+ });
167+
168+ // Heartbeat events
169+ this.eventSource.addEventListener('heartbeat', (event: MessageEvent) => {
170+ trackEventId(event);
171+ try {
172+ const data = JSON.parse(event.data);
173+ this._connectionId = data.connectionId;
174+ this.acknowledgeHeartbeat(data.connectionId);
175+ this.resetHeartbeatMonitor();
176+ } catch (error) {
177+ console.error('[SSEClient] Error parsing heartbeat:', error);
178+ }
179+ });
180+ }
181+
182+ /**
183+ * Start monitoring for heartbeat timeout
184+ */
185+ private startHeartbeatMonitor(): void {
186+ this.clearHeartbeatTimer();
187+
188+ this.heartbeatTimer = setTimeout(() => {
189+ console.warn('[SSEClient] Heartbeat timeout - connection may be stale');
190+ // Force reconnection if no heartbeat received
191+ if (this.eventSource) {
192+ this.eventSource.close();
193+ }
194+ this.handleDisconnection();
195+ }, SSE_CONFIG.HEARTBEAT_TIMEOUT);
196+ }
197+
198+ /**
199+ * Reset the heartbeat monitor (called when heartbeat received)
200+ */
201+ private resetHeartbeatMonitor(): void {
202+ this.startHeartbeatMonitor();
203+ }
204+
205+ /**
206+ * Clear heartbeat timer
207+ */
208+ private clearHeartbeatTimer(): void {
209+ if (this.heartbeatTimer) {
210+ clearTimeout(this.heartbeatTimer);
211+ this.heartbeatTimer = null;
212+ }
213+ }
214+
215+ /**
216+ * Send heartbeat acknowledgment to server
217+ */
218+ private async acknowledgeHeartbeat(connectionId: string): Promise<void> {
219+ try {
220+ const response = await fetch('/api/sse/heartbeat', {
221+ method: 'POST',
222+ headers: { 'Content-Type': 'application/json' },
223+ credentials: 'include',
224+ body: JSON.stringify({ connectionId })
225+ });
226+
227+ if (!response.ok) {
228+ console.warn('[SSEClient] Heartbeat acknowledgment failed:', response.status);
229+ }
230+ } catch (error) {
231+ console.warn('[SSEClient] Failed to acknowledge heartbeat:', error);
232+ }
233+ }
234+
235+ /**
236+ * Handle disconnection with reconnection logic
237+ */
238+ private handleDisconnection(): void {
239+ this.cleanup();
240+ this.setStatus('reconnecting');
241+ this._reconnectAttempts++;
242+
243+ const delay = this.calculateBackoff();
244+ console.debug(
245+ `[SSEClient] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`
246+ );
247+
248+ this.reconnectTimer = setTimeout(() => {
249+ this.connect();
250+ }, delay);
251+ }
252+
253+ /**
254+ * Calculate reconnection delay with exponential backoff and jitter
255+ * Never gives up - always tries to reconnect
256+ */
257+ private calculateBackoff(): number {
258+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
259+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
260+
261+ // Exponential backoff: 2^attempts * base, capped at max
262+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts), max);
263+
264+ // Add random jitter (0-25% of delay) to prevent thundering herd
265+ const jitter = exponentialDelay * Math.random() * 0.25;
266+
267+ return Math.floor(exponentialDelay + jitter);
268+ }
269+
270+ /**
271+ * Get the estimated next reconnect delay in seconds
272+ * Returns null if not reconnecting
273+ */
274+ getReconnectDelay(): number | null {
275+ if (this._status !== 'reconnecting') {
276+ return null;
277+ }
278+ // Calculate the expected delay for the current attempt
279+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
280+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
281+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts - 1), max);
282+ return Math.ceil(exponentialDelay / 1000); // Convert to seconds
283+ }
284+
285+ /**
286+ * Clean up resources
287+ */
288+ private cleanup(): void {
289+ this.clearHeartbeatTimer();
290+
291+ if (this.reconnectTimer) {
292+ clearTimeout(this.reconnectTimer);
293+ this.reconnectTimer = null;
294+ }
295+
296+ if (this.eventSource) {
297+ this.eventSource.close();
298+ this.eventSource = null;
299+ }
300+ }
301+
302+ /**
303+ * Set connection status and notify listener
304+ */
305+ private setStatus(status: ConnectionStatus): void {
306+ this._status = status;
307+ this.config.onStatusChange?.(status);
308+ }
309+
310+ /**
311+ * Destroy the client completely
312+ */
313+ destroy(): void {
314+ console.debug('[SSEClient] Destroying');
315+ this.disconnect();
316+ }
317+}
318+
319+/**
320+ * Factory function to create an SSE client
321+ */
322+export function createSSEClient(config: SSEClientConfig): SSEClient {
323+ return new SSEClient(config);
324+}
@@ -0,0 +1,324 @@
1+/**
2+ * SSE Client - Client-side SSE connection manager
3+ *
4+ * Handles EventSource connections with:
5+ * - Unlimited reconnection with exponential backoff + jitter
6+ * - Heartbeat monitoring and acknowledgment
7+ * - Last-Event-ID tracking for catch-up
8+ * - Reactive state using Svelte 5 runes
9+ */
10+
11+import { SSE_CONFIG, type ConnectionStatus } from './config';
12+import type { SSEEventMap } from './SSEEventEmitter';
13+
14+/**
15+ * Configuration for SSE Client
16+ */
17+export interface SSEClientConfig {
18+ /** SSE endpoint URL */
19+ url: string;
20+ /** Callback for incoming messages */
21+ onMessage: <T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) => void;
22+ /** Callback for connection status changes */
23+ onStatusChange?: (status: ConnectionStatus) => void;
24+ /** Callback for errors */
25+ onError?: (error: string) => void;
26+}
27+
28+/**
29+ * SSE Client with Svelte 5 reactive state
30+ */
31+export class SSEClient {
32+ // Configuration
33+ private config: SSEClientConfig;
34+
35+ // Connection state
36+ private eventSource: EventSource | null = null;
37+ private _connectionId: string | null = null;
38+
39+ // Reactive state using Svelte 5 runes
40+ private _status = $state<ConnectionStatus>('disconnected');
41+ private _lastEventId = $state<string | null>(null);
42+ private _error = $state<string | null>(null);
43+ private _reconnectAttempts = $state(0);
44+
45+ // Timers
46+ private heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
47+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
48+
49+ // Derived state
50+ public status = $derived(this._status);
51+ public lastEventId = $derived(this._lastEventId);
52+ public error = $derived(this._error);
53+ public reconnectAttempts = $derived(this._reconnectAttempts);
54+ public isConnected = $derived(this._status === 'connected');
55+ public isReconnecting = $derived(this._status === 'reconnecting');
56+
57+ constructor(config: SSEClientConfig) {
58+ this.config = config;
59+ console.debug('[SSEClient] Initialized');
60+ }
61+
62+ /**
63+ * Connect to the SSE endpoint
64+ */
65+ connect(): void {
66+ if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
67+ console.debug('[SSEClient] Already connected or connecting');
68+ return;
69+ }
70+
71+ this.setStatus('connecting');
72+ this._error = null;
73+
74+ try {
75+ // EventSource automatically sends Last-Event-ID header on reconnection
76+ this.eventSource = new EventSource(this.config.url, { withCredentials: true });
77+
78+ this.eventSource.onopen = () => {
79+ console.debug('[SSEClient] Connection established');
80+ this.setStatus('connected');
81+ this._reconnectAttempts = 0;
82+ this._error = null;
83+ this.startHeartbeatMonitor();
84+ };
85+
86+ this.eventSource.onerror = (event) => {
87+ console.debug('[SSEClient] Connection error:', event);
88+
89+ if (this.eventSource?.readyState === EventSource.CLOSED) {
90+ this.handleDisconnection();
91+ } else if (this.eventSource?.readyState === EventSource.CONNECTING) {
92+ this.setStatus('reconnecting');
93+ }
94+ };
95+
96+ // Set up event listeners
97+ this.setupEventListeners();
98+ } catch (error) {
99+ console.error('[SSEClient] Failed to create EventSource:', error);
100+ this._error = 'Failed to connect';
101+ this.handleDisconnection();
102+ }
103+ }
104+
105+ /**
106+ * Disconnect from SSE
107+ */
108+ disconnect(): void {
109+ console.debug('[SSEClient] Disconnecting');
110+ this.cleanup();
111+ this.setStatus('disconnected');
112+ this._connectionId = null;
113+ }
114+
115+ /**
116+ * Get the current connection ID
117+ */
118+ getConnectionId(): string | null {
119+ return this._connectionId;
120+ }
121+
122+ /**
123+ * Set up event listeners for SSE events
124+ */
125+ private setupEventListeners(): void {
126+ if (!this.eventSource) return;
127+
128+ // Track last event ID from all events
129+ const trackEventId = (event: MessageEvent) => {
130+ if (event.lastEventId) {
131+ this._lastEventId = event.lastEventId;
132+ }
133+ };
134+
135+ // Chat message events
136+ this.eventSource.addEventListener('chatMessage', (event: MessageEvent) => {
137+ trackEventId(event);
138+ try {
139+ const data = JSON.parse(event.data);
140+ this.config.onMessage('chatMessage', data);
141+ } catch (error) {
142+ console.error('[SSEClient] Error parsing chatMessage:', error);
143+ }
144+ });
145+
146+ // User status update events
147+ this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => {
148+ trackEventId(event);
149+ try {
150+ const data = JSON.parse(event.data);
151+ this.config.onMessage('userStatusUpdate', data);
152+ } catch (error) {
153+ console.error('[SSEClient] Error parsing userStatusUpdate:', error);
154+ }
155+ });
156+
157+ // Buddy list update events
158+ this.eventSource.addEventListener('buddyListUpdate', (event: MessageEvent) => {
159+ trackEventId(event);
160+ try {
161+ const data = JSON.parse(event.data);
162+ this.config.onMessage('buddyListUpdate', data);
163+ } catch (error) {
164+ console.error('[SSEClient] Error parsing buddyListUpdate:', error);
165+ }
166+ });
167+
168+ // Heartbeat events
169+ this.eventSource.addEventListener('heartbeat', (event: MessageEvent) => {
170+ trackEventId(event);
171+ try {
172+ const data = JSON.parse(event.data);
173+ this._connectionId = data.connectionId;
174+ this.acknowledgeHeartbeat(data.connectionId);
175+ this.resetHeartbeatMonitor();
176+ } catch (error) {
177+ console.error('[SSEClient] Error parsing heartbeat:', error);
178+ }
179+ });
180+ }
181+
182+ /**
183+ * Start monitoring for heartbeat timeout
184+ */
185+ private startHeartbeatMonitor(): void {
186+ this.clearHeartbeatTimer();
187+
188+ this.heartbeatTimer = setTimeout(() => {
189+ console.warn('[SSEClient] Heartbeat timeout - connection may be stale');
190+ // Force reconnection if no heartbeat received
191+ if (this.eventSource) {
192+ this.eventSource.close();
193+ }
194+ this.handleDisconnection();
195+ }, SSE_CONFIG.HEARTBEAT_TIMEOUT);
196+ }
197+
198+ /**
199+ * Reset the heartbeat monitor (called when heartbeat received)
200+ */
201+ private resetHeartbeatMonitor(): void {
202+ this.startHeartbeatMonitor();
203+ }
204+
205+ /**
206+ * Clear heartbeat timer
207+ */
208+ private clearHeartbeatTimer(): void {
209+ if (this.heartbeatTimer) {
210+ clearTimeout(this.heartbeatTimer);
211+ this.heartbeatTimer = null;
212+ }
213+ }
214+
215+ /**
216+ * Send heartbeat acknowledgment to server
217+ */
218+ private async acknowledgeHeartbeat(connectionId: string): Promise<void> {
219+ try {
220+ const response = await fetch('/api/sse/heartbeat', {
221+ method: 'POST',
222+ headers: { 'Content-Type': 'application/json' },
223+ credentials: 'include',
224+ body: JSON.stringify({ connectionId })
225+ });
226+
227+ if (!response.ok) {
228+ console.warn('[SSEClient] Heartbeat acknowledgment failed:', response.status);
229+ }
230+ } catch (error) {
231+ console.warn('[SSEClient] Failed to acknowledge heartbeat:', error);
232+ }
233+ }
234+
235+ /**
236+ * Handle disconnection with reconnection logic
237+ */
238+ private handleDisconnection(): void {
239+ this.cleanup();
240+ this.setStatus('reconnecting');
241+ this._reconnectAttempts++;
242+
243+ const delay = this.calculateBackoff();
244+ console.debug(
245+ `[SSEClient] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`
246+ );
247+
248+ this.reconnectTimer = setTimeout(() => {
249+ this.connect();
250+ }, delay);
251+ }
252+
253+ /**
254+ * Calculate reconnection delay with exponential backoff and jitter
255+ * Never gives up - always tries to reconnect
256+ */
257+ private calculateBackoff(): number {
258+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
259+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
260+
261+ // Exponential backoff: 2^attempts * base, capped at max
262+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts), max);
263+
264+ // Add random jitter (0-25% of delay) to prevent thundering herd
265+ const jitter = exponentialDelay * Math.random() * 0.25;
266+
267+ return Math.floor(exponentialDelay + jitter);
268+ }
269+
270+ /**
271+ * Get the estimated next reconnect delay in seconds
272+ * Returns null if not reconnecting
273+ */
274+ getReconnectDelay(): number | null {
275+ if (this._status !== 'reconnecting') {
276+ return null;
277+ }
278+ // Calculate the expected delay for the current attempt
279+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
280+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
281+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts - 1), max);
282+ return Math.ceil(exponentialDelay / 1000); // Convert to seconds
283+ }
284+
285+ /**
286+ * Clean up resources
287+ */
288+ private cleanup(): void {
289+ this.clearHeartbeatTimer();
290+
291+ if (this.reconnectTimer) {
292+ clearTimeout(this.reconnectTimer);
293+ this.reconnectTimer = null;
294+ }
295+
296+ if (this.eventSource) {
297+ this.eventSource.close();
298+ this.eventSource = null;
299+ }
300+ }
301+
302+ /**
303+ * Set connection status and notify listener
304+ */
305+ private setStatus(status: ConnectionStatus): void {
306+ this._status = status;
307+ this.config.onStatusChange?.(status);
308+ }
309+
310+ /**
311+ * Destroy the client completely
312+ */
313+ destroy(): void {
314+ console.debug('[SSEClient] Destroying');
315+ this.disconnect();
316+ }
317+}
318+
319+/**
320+ * Factory function to create an SSE client
321+ */
322+export function createSSEClient(config: SSEClientConfig): SSEClient {
323+ return new SSEClient(config);
324+}
A src/lib/sse/SSEEventEmitter.ts +188 -0
@@ -0,0 +1,188 @@
1+/**
2+ * Type-safe SSE Event Emitter
3+ *
4+ * Provides typed event emission for SSE broadcasts with automatic
5+ * listener cleanup and memory leak prevention.
6+ */
7+
8+import type { Message, SafeUser, UserStatus } from '../types/chat';
9+
10+/**
11+ * SSE Event Type Map - defines the payload for each event type
12+ */
13+export interface SSEEventMap {
14+ chatMessage: Message;
15+ userStatusUpdate: {
16+ userId: string;
17+ status: UserStatus;
18+ lastSeen: number;
19+ };
20+ buddyListUpdate: SafeUser[];
21+ heartbeat: {
22+ connectionId: string;
23+ timestamp: number;
24+ serverTime: string;
25+ };
26+}
27+
28+/**
29+ * Generic SSE event structure sent over the wire
30+ */
31+export interface SSEEvent<T extends keyof SSEEventMap = keyof SSEEventMap> {
32+ type: T;
33+ data: SSEEventMap[T];
34+ id?: string; // Event ID for Last-Event-ID support
35+}
36+
37+/**
38+ * Listener function type for SSE events
39+ */
40+type SSEListener = (event: SSEEvent) => void;
41+
42+/**
43+ * Type-safe SSE Event Emitter with automatic cleanup
44+ */
45+class SSEEventEmitter {
46+ private listeners: Map<string, Set<SSEListener>> = new Map();
47+ private maxListeners: number = 100;
48+ private listenerCount: number = 0;
49+
50+ constructor() {
51+ console.log('[SSE-Emitter] Initialized');
52+ }
53+
54+ /**
55+ * Emit an SSE event to all listeners
56+ */
57+ emit<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], id?: string): void {
58+ const event: SSEEvent<T> = { type, data, id };
59+
60+ // Get listeners for the 'sse' channel (main broadcast channel)
61+ const eventListeners = this.listeners.get('sse');
62+ if (eventListeners && eventListeners.size > 0) {
63+ // Only log non-frequent event types
64+ if (!['userStatusUpdate', 'chatMessage', 'heartbeat'].includes(type)) {
65+ console.log('[SSE-Emitter] Broadcasting event:', {
66+ type,
67+ listenersCount: eventListeners.size
68+ });
69+ }
70+
71+ // Iterate over a copy to allow removal during iteration
72+ for (const listener of [...eventListeners]) {
73+ try {
74+ listener(event as SSEEvent);
75+ } catch (error) {
76+ console.error('[SSE-Emitter] Error in listener:', error);
77+ // Remove broken listener
78+ this.removeListener('sse', listener);
79+ }
80+ }
81+ }
82+ }
83+
84+ /**
85+ * Add a listener for SSE events
86+ * Returns a cleanup function for easy removal
87+ */
88+ addListener(channel: string, listener: SSEListener): () => void {
89+ if (!this.listeners.has(channel)) {
90+ this.listeners.set(channel, new Set());
91+ }
92+
93+ const channelListeners = this.listeners.get(channel)!;
94+
95+ if (channelListeners.size >= this.maxListeners) {
96+ console.warn(
97+ `[SSE-Emitter] Warning: Channel '${channel}' has reached max listeners (${this.maxListeners})`
98+ );
99+ }
100+
101+ channelListeners.add(listener);
102+ this.listenerCount++;
103+
104+ console.log('[SSE-Emitter] Listener added:', {
105+ channel,
106+ totalListeners: channelListeners.size,
107+ globalTotal: this.listenerCount
108+ });
109+
110+ // Return cleanup function
111+ return () => this.removeListener(channel, listener);
112+ }
113+
114+ /**
115+ * Remove a specific listener
116+ */
117+ removeListener(channel: string, listener: SSEListener): boolean {
118+ const channelListeners = this.listeners.get(channel);
119+ if (channelListeners) {
120+ const removed = channelListeners.delete(listener);
121+ if (removed) {
122+ this.listenerCount--;
123+ console.log('[SSE-Emitter] Listener removed:', {
124+ channel,
125+ remainingListeners: channelListeners.size,
126+ globalTotal: this.listenerCount
127+ });
128+
129+ // Clean up empty channels
130+ if (channelListeners.size === 0) {
131+ this.listeners.delete(channel);
132+ }
133+ return true;
134+ }
135+ }
136+ return false;
137+ }
138+
139+ /**
140+ * Remove all listeners for a channel
141+ */
142+ removeAllListeners(channel?: string): void {
143+ if (channel) {
144+ const channelListeners = this.listeners.get(channel);
145+ if (channelListeners) {
146+ this.listenerCount -= channelListeners.size;
147+ this.listeners.delete(channel);
148+ console.log('[SSE-Emitter] All listeners removed for channel:', channel);
149+ }
150+ } else {
151+ this.listeners.clear();
152+ this.listenerCount = 0;
153+ console.log('[SSE-Emitter] All listeners removed');
154+ }
155+ }
156+
157+ /**
158+ * Get the count of listeners for a channel
159+ */
160+ getListenerCount(channel?: string): number {
161+ if (channel) {
162+ return this.listeners.get(channel)?.size ?? 0;
163+ }
164+ return this.listenerCount;
165+ }
166+
167+ /**
168+ * Set the maximum number of listeners per channel
169+ */
170+ setMaxListeners(n: number): void {
171+ this.maxListeners = n;
172+ console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n });
173+ }
174+
175+ /**
176+ * Check if a channel has any listeners
177+ */
178+ hasListeners(channel: string): boolean {
179+ const channelListeners = this.listeners.get(channel);
180+ return channelListeners !== undefined && channelListeners.size > 0;
181+ }
182+}
183+
184+// Singleton instance
185+export const sseEmitter = new SSEEventEmitter();
186+
187+// Export type for use in other modules
188+export type { SSEListener };
@@ -0,0 +1,188 @@
1+/**
2+ * Type-safe SSE Event Emitter
3+ *
4+ * Provides typed event emission for SSE broadcasts with automatic
5+ * listener cleanup and memory leak prevention.
6+ */
7+
8+import type { Message, SafeUser, UserStatus } from '../types/chat';
9+
10+/**
11+ * SSE Event Type Map - defines the payload for each event type
12+ */
13+export interface SSEEventMap {
14+ chatMessage: Message;
15+ userStatusUpdate: {
16+ userId: string;
17+ status: UserStatus;
18+ lastSeen: number;
19+ };
20+ buddyListUpdate: SafeUser[];
21+ heartbeat: {
22+ connectionId: string;
23+ timestamp: number;
24+ serverTime: string;
25+ };
26+}
27+
28+/**
29+ * Generic SSE event structure sent over the wire
30+ */
31+export interface SSEEvent<T extends keyof SSEEventMap = keyof SSEEventMap> {
32+ type: T;
33+ data: SSEEventMap[T];
34+ id?: string; // Event ID for Last-Event-ID support
35+}
36+
37+/**
38+ * Listener function type for SSE events
39+ */
40+type SSEListener = (event: SSEEvent) => void;
41+
42+/**
43+ * Type-safe SSE Event Emitter with automatic cleanup
44+ */
45+class SSEEventEmitter {
46+ private listeners: Map<string, Set<SSEListener>> = new Map();
47+ private maxListeners: number = 100;
48+ private listenerCount: number = 0;
49+
50+ constructor() {
51+ console.log('[SSE-Emitter] Initialized');
52+ }
53+
54+ /**
55+ * Emit an SSE event to all listeners
56+ */
57+ emit<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], id?: string): void {
58+ const event: SSEEvent<T> = { type, data, id };
59+
60+ // Get listeners for the 'sse' channel (main broadcast channel)
61+ const eventListeners = this.listeners.get('sse');
62+ if (eventListeners && eventListeners.size > 0) {
63+ // Only log non-frequent event types
64+ if (!['userStatusUpdate', 'chatMessage', 'heartbeat'].includes(type)) {
65+ console.log('[SSE-Emitter] Broadcasting event:', {
66+ type,
67+ listenersCount: eventListeners.size
68+ });
69+ }
70+
71+ // Iterate over a copy to allow removal during iteration
72+ for (const listener of [...eventListeners]) {
73+ try {
74+ listener(event as SSEEvent);
75+ } catch (error) {
76+ console.error('[SSE-Emitter] Error in listener:', error);
77+ // Remove broken listener
78+ this.removeListener('sse', listener);
79+ }
80+ }
81+ }
82+ }
83+
84+ /**
85+ * Add a listener for SSE events
86+ * Returns a cleanup function for easy removal
87+ */
88+ addListener(channel: string, listener: SSEListener): () => void {
89+ if (!this.listeners.has(channel)) {
90+ this.listeners.set(channel, new Set());
91+ }
92+
93+ const channelListeners = this.listeners.get(channel)!;
94+
95+ if (channelListeners.size >= this.maxListeners) {
96+ console.warn(
97+ `[SSE-Emitter] Warning: Channel '${channel}' has reached max listeners (${this.maxListeners})`
98+ );
99+ }
100+
101+ channelListeners.add(listener);
102+ this.listenerCount++;
103+
104+ console.log('[SSE-Emitter] Listener added:', {
105+ channel,
106+ totalListeners: channelListeners.size,
107+ globalTotal: this.listenerCount
108+ });
109+
110+ // Return cleanup function
111+ return () => this.removeListener(channel, listener);
112+ }
113+
114+ /**
115+ * Remove a specific listener
116+ */
117+ removeListener(channel: string, listener: SSEListener): boolean {
118+ const channelListeners = this.listeners.get(channel);
119+ if (channelListeners) {
120+ const removed = channelListeners.delete(listener);
121+ if (removed) {
122+ this.listenerCount--;
123+ console.log('[SSE-Emitter] Listener removed:', {
124+ channel,
125+ remainingListeners: channelListeners.size,
126+ globalTotal: this.listenerCount
127+ });
128+
129+ // Clean up empty channels
130+ if (channelListeners.size === 0) {
131+ this.listeners.delete(channel);
132+ }
133+ return true;
134+ }
135+ }
136+ return false;
137+ }
138+
139+ /**
140+ * Remove all listeners for a channel
141+ */
142+ removeAllListeners(channel?: string): void {
143+ if (channel) {
144+ const channelListeners = this.listeners.get(channel);
145+ if (channelListeners) {
146+ this.listenerCount -= channelListeners.size;
147+ this.listeners.delete(channel);
148+ console.log('[SSE-Emitter] All listeners removed for channel:', channel);
149+ }
150+ } else {
151+ this.listeners.clear();
152+ this.listenerCount = 0;
153+ console.log('[SSE-Emitter] All listeners removed');
154+ }
155+ }
156+
157+ /**
158+ * Get the count of listeners for a channel
159+ */
160+ getListenerCount(channel?: string): number {
161+ if (channel) {
162+ return this.listeners.get(channel)?.size ?? 0;
163+ }
164+ return this.listenerCount;
165+ }
166+
167+ /**
168+ * Set the maximum number of listeners per channel
169+ */
170+ setMaxListeners(n: number): void {
171+ this.maxListeners = n;
172+ console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n });
173+ }
174+
175+ /**
176+ * Check if a channel has any listeners
177+ */
178+ hasListeners(channel: string): boolean {
179+ const channelListeners = this.listeners.get(channel);
180+ return channelListeners !== undefined && channelListeners.size > 0;
181+ }
182+}
183+
184+// Singleton instance
185+export const sseEmitter = new SSEEventEmitter();
186+
187+// Export type for use in other modules
188+export type { SSEListener };
A src/lib/sse/SSEManager.ts +373 -0
@@ -0,0 +1,373 @@
1+/**
2+ * SSE Manager - Central server-side manager for SSE connections
3+ *
4+ * Coordinates connection tracking, message queuing, event broadcasting,
5+ * and heartbeat management.
6+ */
7+
8+import { SSE_CONFIG } from './config';
9+import { sseEmitter, type SSEEventMap, type SSEEvent } from './SSEEventEmitter';
10+import { messageQueue } from './MessageQueue';
11+import { createLogger } from '../utils/logger.server';
12+
13+const log = createLogger('sse-manager');
14+
15+/**
16+ * Enhanced connection info with heartbeat tracking
17+ */
18+export interface SSEConnection {
19+ /** Unique connection ID */
20+ connectionId: string;
21+ /** User ID */
22+ userId: string;
23+ /** When the connection was established */
24+ connectedAt: number;
25+ /** Last activity timestamp */
26+ lastActivity: number;
27+ /** Last heartbeat acknowledgment */
28+ lastHeartbeat: number;
29+ /** Current room ID */
30+ roomId: string;
31+ /** Stream controller for sending events */
32+ controller: ReadableStreamDefaultController<Uint8Array> | null;
33+}
34+
35+/**
36+ * SSE Manager singleton
37+ */
38+class SSEManager {
39+ /** Map of connectionId -> SSEConnection */
40+ private connections: Map<string, SSEConnection> = new Map();
41+ /** Map of userId -> Set of connectionIds (for multi-tab support) */
42+ private userConnections: Map<string, Set<string>> = new Map();
43+ /** Text encoder for SSE output */
44+ private encoder = new TextEncoder();
45+ /** Intervals for periodic tasks */
46+ private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
47+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
48+
49+ constructor() {
50+ log.info('SSE Manager initialized');
51+ }
52+
53+ /**
54+ * Start background tasks (heartbeat, cleanup)
55+ */
56+ startBackgroundTasks(): void {
57+ if (this.heartbeatInterval || this.cleanupInterval) {
58+ log.debug('Background tasks already running');
59+ return;
60+ }
61+
62+ // Send heartbeats to all connections
63+ this.heartbeatInterval = setInterval(() => {
64+ this.sendHeartbeats();
65+ }, SSE_CONFIG.HEARTBEAT_INTERVAL);
66+
67+ // Cleanup stale connections
68+ this.cleanupInterval = setInterval(() => {
69+ this.cleanupStaleConnections();
70+ }, SSE_CONFIG.STALE_CHECK_INTERVAL);
71+
72+ log.info('Background tasks started');
73+ }
74+
75+ /**
76+ * Stop background tasks
77+ */
78+ stopBackgroundTasks(): void {
79+ if (this.heartbeatInterval) {
80+ clearInterval(this.heartbeatInterval);
81+ this.heartbeatInterval = null;
82+ }
83+ if (this.cleanupInterval) {
84+ clearInterval(this.cleanupInterval);
85+ this.cleanupInterval = null;
86+ }
87+ log.info('Background tasks stopped');
88+ }
89+
90+ /**
91+ * Add a new SSE connection
92+ * @returns Connection ID and whether this is a new user connection
93+ */
94+ addConnection(
95+ userId: string,
96+ roomId: string,
97+ controller: ReadableStreamDefaultController<Uint8Array>
98+ ): { connectionId: string; isNewUser: boolean } {
99+ const connectionId = crypto.randomUUID();
100+ const now = Date.now();
101+
102+ const connection: SSEConnection = {
103+ connectionId,
104+ userId,
105+ connectedAt: now,
106+ lastActivity: now,
107+ lastHeartbeat: now,
108+ roomId,
109+ controller
110+ };
111+
112+ this.connections.set(connectionId, connection);
113+
114+ // Track user -> connections mapping
115+ let userConns = this.userConnections.get(userId);
116+ const isNewUser = !userConns || userConns.size === 0;
117+
118+ if (!userConns) {
119+ userConns = new Set();
120+ this.userConnections.set(userId, userConns);
121+ }
122+ userConns.add(connectionId);
123+
124+ log.info('Connection added', {
125+ connectionId: connectionId.slice(0, 8),
126+ userId: userId.slice(0, 8),
127+ isNewUser,
128+ userConnectionCount: userConns.size,
129+ totalConnections: this.connections.size
130+ });
131+
132+ return { connectionId, isNewUser };
133+ }
134+
135+ /**
136+ * Remove a connection
137+ * @returns Whether this was the last connection for the user
138+ */
139+ removeConnection(connectionId: string): { removed: boolean; isLastConnection: boolean; userId: string | null } {
140+ const connection = this.connections.get(connectionId);
141+ if (!connection) {
142+ return { removed: false, isLastConnection: false, userId: null };
143+ }
144+
145+ const { userId } = connection;
146+ this.connections.delete(connectionId);
147+
148+ // Update user connections map
149+ const userConns = this.userConnections.get(userId);
150+ if (userConns) {
151+ userConns.delete(connectionId);
152+ if (userConns.size === 0) {
153+ this.userConnections.delete(userId);
154+ }
155+ }
156+
157+ const isLastConnection = !userConns || userConns.size === 0;
158+
159+ log.info('Connection removed', {
160+ connectionId: connectionId.slice(0, 8),
161+ userId: userId.slice(0, 8),
162+ isLastConnection,
163+ remainingConnections: this.connections.size
164+ });
165+
166+ return { removed: true, isLastConnection, userId };
167+ }
168+
169+ /**
170+ * Get a connection by ID
171+ */
172+ getConnection(connectionId: string): SSEConnection | undefined {
173+ return this.connections.get(connectionId);
174+ }
175+
176+ /**
177+ * Check if a user has any active connections
178+ */
179+ hasUserConnection(userId: string): boolean {
180+ const userConns = this.userConnections.get(userId);
181+ return userConns !== undefined && userConns.size > 0;
182+ }
183+
184+ /**
185+ * Get all connection IDs for a user
186+ */
187+ getUserConnectionIds(userId: string): string[] {
188+ const userConns = this.userConnections.get(userId);
189+ return userConns ? Array.from(userConns) : [];
190+ }
191+
192+ /**
193+ * Update activity timestamp for a connection
194+ */
195+ updateActivity(connectionId: string): void {
196+ const connection = this.connections.get(connectionId);
197+ if (connection) {
198+ connection.lastActivity = Date.now();
199+ }
200+ }
201+
202+ /**
203+ * Acknowledge heartbeat for a connection
204+ */
205+ acknowledgeHeartbeat(connectionId: string): boolean {
206+ const connection = this.connections.get(connectionId);
207+ if (connection) {
208+ connection.lastHeartbeat = Date.now();
209+ connection.lastActivity = Date.now();
210+ log.debug('Heartbeat acknowledged', { connectionId: connectionId.slice(0, 8) });
211+ return true;
212+ }
213+ return false;
214+ }
215+
216+ /**
217+ * Send an event to a specific connection
218+ */
219+ sendToConnection(connectionId: string, event: SSEEvent, eventId?: string): boolean {
220+ const connection = this.connections.get(connectionId);
221+ if (!connection || !connection.controller) {
222+ return false;
223+ }
224+
225+ try {
226+ const id = eventId ?? crypto.randomUUID();
227+ const payload = this.formatSSEEvent(event, id);
228+ connection.controller.enqueue(this.encoder.encode(payload));
229+
230+ // Queue for catch-up
231+ messageQueue.enqueue(connection.roomId, event.type, event.data);
232+
233+ return true;
234+ } catch (error) {
235+ log.error('Error sending to connection', { connectionId: connectionId.slice(0, 8), error });
236+ return false;
237+ }
238+ }
239+
240+ /**
241+ * Send an event to a specific user (all their connections)
242+ */
243+ sendToUser(userId: string, event: SSEEvent): number {
244+ const connectionIds = this.getUserConnectionIds(userId);
245+ let sent = 0;
246+ for (const connId of connectionIds) {
247+ if (this.sendToConnection(connId, event)) {
248+ sent++;
249+ }
250+ }
251+ return sent;
252+ }
253+
254+ /**
255+ * Broadcast an event to all connections
256+ * Uses the event emitter for distribution
257+ */
258+ broadcast<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], roomId?: string): void {
259+ const eventId = crypto.randomUUID();
260+
261+ // Queue the message first
262+ if (roomId) {
263+ messageQueue.enqueue(roomId, type, data);
264+ }
265+
266+ // Emit through the event emitter (listeners on SSE endpoint will pick this up)
267+ sseEmitter.emit(type, data, eventId);
268+ }
269+
270+ /**
271+ * Get messages since a specific event ID (for catch-up)
272+ */
273+ getMessagesSince(roomId: string, lastEventId: string) {
274+ return messageQueue.getMessagesSince(roomId, lastEventId);
275+ }
276+
277+ /**
278+ * Format an SSE event for transmission
279+ */
280+ formatSSEEvent(event: SSEEvent, eventId: string): string {
281+ const lines = [
282+ `id: ${eventId}`,
283+ `event: ${event.type}`,
284+ `data: ${JSON.stringify(event.data)}`,
285+ `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
286+ '',
287+ ''
288+ ];
289+ return lines.join('\n');
290+ }
291+
292+ /**
293+ * Format a keep-alive comment
294+ */
295+ formatKeepAlive(): string {
296+ return ':\n\n';
297+ }
298+
299+ /**
300+ * Send heartbeats to all connections
301+ */
302+ private sendHeartbeats(): void {
303+ const now = Date.now();
304+ let sent = 0;
305+
306+ for (const [connectionId, connection] of this.connections) {
307+ if (connection.controller) {
308+ try {
309+ const heartbeatEvent: SSEEvent<'heartbeat'> = {
310+ type: 'heartbeat',
311+ data: {
312+ connectionId,
313+ timestamp: now,
314+ serverTime: new Date(now).toISOString()
315+ }
316+ };
317+ const payload = this.formatSSEEvent(heartbeatEvent, crypto.randomUUID());
318+ connection.controller.enqueue(this.encoder.encode(payload));
319+ sent++;
320+ } catch {
321+ // Connection might be closed, will be cleaned up later
322+ }
323+ }
324+ }
325+
326+ if (sent > 0) {
327+ log.debug('Heartbeats sent', { count: sent });
328+ }
329+ }
330+
331+ /**
332+ * Clean up stale connections
333+ */
334+ private cleanupStaleConnections(): void {
335+ const now = Date.now();
336+ const staleConnections: string[] = [];
337+
338+ for (const [connectionId, connection] of this.connections) {
339+ const timeSinceHeartbeat = now - connection.lastHeartbeat;
340+ if (timeSinceHeartbeat > SSE_CONFIG.HEARTBEAT_TIMEOUT) {
341+ staleConnections.push(connectionId);
342+ }
343+ }
344+
345+ if (staleConnections.length > 0) {
346+ log.info('Cleaning up stale connections', { count: staleConnections.length });
347+ for (const connId of staleConnections) {
348+ this.removeConnection(connId);
349+ }
350+ }
351+ }
352+
353+ /**
354+ * Get all active user IDs
355+ */
356+ getActiveUserIds(): string[] {
357+ return Array.from(this.userConnections.keys());
358+ }
359+
360+ /**
361+ * Get connection statistics
362+ */
363+ getStats(): { totalConnections: number; uniqueUsers: number; queueStats: { rooms: number; totalMessages: number } } {
364+ return {
365+ totalConnections: this.connections.size,
366+ uniqueUsers: this.userConnections.size,
367+ queueStats: messageQueue.getStats()
368+ };
369+ }
370+}
371+
372+// Singleton instance
373+export const sseManager = new SSEManager();
@@ -0,0 +1,373 @@
1+/**
2+ * SSE Manager - Central server-side manager for SSE connections
3+ *
4+ * Coordinates connection tracking, message queuing, event broadcasting,
5+ * and heartbeat management.
6+ */
7+
8+import { SSE_CONFIG } from './config';
9+import { sseEmitter, type SSEEventMap, type SSEEvent } from './SSEEventEmitter';
10+import { messageQueue } from './MessageQueue';
11+import { createLogger } from '../utils/logger.server';
12+
13+const log = createLogger('sse-manager');
14+
15+/**
16+ * Enhanced connection info with heartbeat tracking
17+ */
18+export interface SSEConnection {
19+ /** Unique connection ID */
20+ connectionId: string;
21+ /** User ID */
22+ userId: string;
23+ /** When the connection was established */
24+ connectedAt: number;
25+ /** Last activity timestamp */
26+ lastActivity: number;
27+ /** Last heartbeat acknowledgment */
28+ lastHeartbeat: number;
29+ /** Current room ID */
30+ roomId: string;
31+ /** Stream controller for sending events */
32+ controller: ReadableStreamDefaultController<Uint8Array> | null;
33+}
34+
35+/**
36+ * SSE Manager singleton
37+ */
38+class SSEManager {
39+ /** Map of connectionId -> SSEConnection */
40+ private connections: Map<string, SSEConnection> = new Map();
41+ /** Map of userId -> Set of connectionIds (for multi-tab support) */
42+ private userConnections: Map<string, Set<string>> = new Map();
43+ /** Text encoder for SSE output */
44+ private encoder = new TextEncoder();
45+ /** Intervals for periodic tasks */
46+ private heartbeatInterval: ReturnType<typeof setInterval> | null = null;
47+ private cleanupInterval: ReturnType<typeof setInterval> | null = null;
48+
49+ constructor() {
50+ log.info('SSE Manager initialized');
51+ }
52+
53+ /**
54+ * Start background tasks (heartbeat, cleanup)
55+ */
56+ startBackgroundTasks(): void {
57+ if (this.heartbeatInterval || this.cleanupInterval) {
58+ log.debug('Background tasks already running');
59+ return;
60+ }
61+
62+ // Send heartbeats to all connections
63+ this.heartbeatInterval = setInterval(() => {
64+ this.sendHeartbeats();
65+ }, SSE_CONFIG.HEARTBEAT_INTERVAL);
66+
67+ // Cleanup stale connections
68+ this.cleanupInterval = setInterval(() => {
69+ this.cleanupStaleConnections();
70+ }, SSE_CONFIG.STALE_CHECK_INTERVAL);
71+
72+ log.info('Background tasks started');
73+ }
74+
75+ /**
76+ * Stop background tasks
77+ */
78+ stopBackgroundTasks(): void {
79+ if (this.heartbeatInterval) {
80+ clearInterval(this.heartbeatInterval);
81+ this.heartbeatInterval = null;
82+ }
83+ if (this.cleanupInterval) {
84+ clearInterval(this.cleanupInterval);
85+ this.cleanupInterval = null;
86+ }
87+ log.info('Background tasks stopped');
88+ }
89+
90+ /**
91+ * Add a new SSE connection
92+ * @returns Connection ID and whether this is a new user connection
93+ */
94+ addConnection(
95+ userId: string,
96+ roomId: string,
97+ controller: ReadableStreamDefaultController<Uint8Array>
98+ ): { connectionId: string; isNewUser: boolean } {
99+ const connectionId = crypto.randomUUID();
100+ const now = Date.now();
101+
102+ const connection: SSEConnection = {
103+ connectionId,
104+ userId,
105+ connectedAt: now,
106+ lastActivity: now,
107+ lastHeartbeat: now,
108+ roomId,
109+ controller
110+ };
111+
112+ this.connections.set(connectionId, connection);
113+
114+ // Track user -> connections mapping
115+ let userConns = this.userConnections.get(userId);
116+ const isNewUser = !userConns || userConns.size === 0;
117+
118+ if (!userConns) {
119+ userConns = new Set();
120+ this.userConnections.set(userId, userConns);
121+ }
122+ userConns.add(connectionId);
123+
124+ log.info('Connection added', {
125+ connectionId: connectionId.slice(0, 8),
126+ userId: userId.slice(0, 8),
127+ isNewUser,
128+ userConnectionCount: userConns.size,
129+ totalConnections: this.connections.size
130+ });
131+
132+ return { connectionId, isNewUser };
133+ }
134+
135+ /**
136+ * Remove a connection
137+ * @returns Whether this was the last connection for the user
138+ */
139+ removeConnection(connectionId: string): { removed: boolean; isLastConnection: boolean; userId: string | null } {
140+ const connection = this.connections.get(connectionId);
141+ if (!connection) {
142+ return { removed: false, isLastConnection: false, userId: null };
143+ }
144+
145+ const { userId } = connection;
146+ this.connections.delete(connectionId);
147+
148+ // Update user connections map
149+ const userConns = this.userConnections.get(userId);
150+ if (userConns) {
151+ userConns.delete(connectionId);
152+ if (userConns.size === 0) {
153+ this.userConnections.delete(userId);
154+ }
155+ }
156+
157+ const isLastConnection = !userConns || userConns.size === 0;
158+
159+ log.info('Connection removed', {
160+ connectionId: connectionId.slice(0, 8),
161+ userId: userId.slice(0, 8),
162+ isLastConnection,
163+ remainingConnections: this.connections.size
164+ });
165+
166+ return { removed: true, isLastConnection, userId };
167+ }
168+
169+ /**
170+ * Get a connection by ID
171+ */
172+ getConnection(connectionId: string): SSEConnection | undefined {
173+ return this.connections.get(connectionId);
174+ }
175+
176+ /**
177+ * Check if a user has any active connections
178+ */
179+ hasUserConnection(userId: string): boolean {
180+ const userConns = this.userConnections.get(userId);
181+ return userConns !== undefined && userConns.size > 0;
182+ }
183+
184+ /**
185+ * Get all connection IDs for a user
186+ */
187+ getUserConnectionIds(userId: string): string[] {
188+ const userConns = this.userConnections.get(userId);
189+ return userConns ? Array.from(userConns) : [];
190+ }
191+
192+ /**
193+ * Update activity timestamp for a connection
194+ */
195+ updateActivity(connectionId: string): void {
196+ const connection = this.connections.get(connectionId);
197+ if (connection) {
198+ connection.lastActivity = Date.now();
199+ }
200+ }
201+
202+ /**
203+ * Acknowledge heartbeat for a connection
204+ */
205+ acknowledgeHeartbeat(connectionId: string): boolean {
206+ const connection = this.connections.get(connectionId);
207+ if (connection) {
208+ connection.lastHeartbeat = Date.now();
209+ connection.lastActivity = Date.now();
210+ log.debug('Heartbeat acknowledged', { connectionId: connectionId.slice(0, 8) });
211+ return true;
212+ }
213+ return false;
214+ }
215+
216+ /**
217+ * Send an event to a specific connection
218+ */
219+ sendToConnection(connectionId: string, event: SSEEvent, eventId?: string): boolean {
220+ const connection = this.connections.get(connectionId);
221+ if (!connection || !connection.controller) {
222+ return false;
223+ }
224+
225+ try {
226+ const id = eventId ?? crypto.randomUUID();
227+ const payload = this.formatSSEEvent(event, id);
228+ connection.controller.enqueue(this.encoder.encode(payload));
229+
230+ // Queue for catch-up
231+ messageQueue.enqueue(connection.roomId, event.type, event.data);
232+
233+ return true;
234+ } catch (error) {
235+ log.error('Error sending to connection', { connectionId: connectionId.slice(0, 8), error });
236+ return false;
237+ }
238+ }
239+
240+ /**
241+ * Send an event to a specific user (all their connections)
242+ */
243+ sendToUser(userId: string, event: SSEEvent): number {
244+ const connectionIds = this.getUserConnectionIds(userId);
245+ let sent = 0;
246+ for (const connId of connectionIds) {
247+ if (this.sendToConnection(connId, event)) {
248+ sent++;
249+ }
250+ }
251+ return sent;
252+ }
253+
254+ /**
255+ * Broadcast an event to all connections
256+ * Uses the event emitter for distribution
257+ */
258+ broadcast<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], roomId?: string): void {
259+ const eventId = crypto.randomUUID();
260+
261+ // Queue the message first
262+ if (roomId) {
263+ messageQueue.enqueue(roomId, type, data);
264+ }
265+
266+ // Emit through the event emitter (listeners on SSE endpoint will pick this up)
267+ sseEmitter.emit(type, data, eventId);
268+ }
269+
270+ /**
271+ * Get messages since a specific event ID (for catch-up)
272+ */
273+ getMessagesSince(roomId: string, lastEventId: string) {
274+ return messageQueue.getMessagesSince(roomId, lastEventId);
275+ }
276+
277+ /**
278+ * Format an SSE event for transmission
279+ */
280+ formatSSEEvent(event: SSEEvent, eventId: string): string {
281+ const lines = [
282+ `id: ${eventId}`,
283+ `event: ${event.type}`,
284+ `data: ${JSON.stringify(event.data)}`,
285+ `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
286+ '',
287+ ''
288+ ];
289+ return lines.join('\n');
290+ }
291+
292+ /**
293+ * Format a keep-alive comment
294+ */
295+ formatKeepAlive(): string {
296+ return ':\n\n';
297+ }
298+
299+ /**
300+ * Send heartbeats to all connections
301+ */
302+ private sendHeartbeats(): void {
303+ const now = Date.now();
304+ let sent = 0;
305+
306+ for (const [connectionId, connection] of this.connections) {
307+ if (connection.controller) {
308+ try {
309+ const heartbeatEvent: SSEEvent<'heartbeat'> = {
310+ type: 'heartbeat',
311+ data: {
312+ connectionId,
313+ timestamp: now,
314+ serverTime: new Date(now).toISOString()
315+ }
316+ };
317+ const payload = this.formatSSEEvent(heartbeatEvent, crypto.randomUUID());
318+ connection.controller.enqueue(this.encoder.encode(payload));
319+ sent++;
320+ } catch {
321+ // Connection might be closed, will be cleaned up later
322+ }
323+ }
324+ }
325+
326+ if (sent > 0) {
327+ log.debug('Heartbeats sent', { count: sent });
328+ }
329+ }
330+
331+ /**
332+ * Clean up stale connections
333+ */
334+ private cleanupStaleConnections(): void {
335+ const now = Date.now();
336+ const staleConnections: string[] = [];
337+
338+ for (const [connectionId, connection] of this.connections) {
339+ const timeSinceHeartbeat = now - connection.lastHeartbeat;
340+ if (timeSinceHeartbeat > SSE_CONFIG.HEARTBEAT_TIMEOUT) {
341+ staleConnections.push(connectionId);
342+ }
343+ }
344+
345+ if (staleConnections.length > 0) {
346+ log.info('Cleaning up stale connections', { count: staleConnections.length });
347+ for (const connId of staleConnections) {
348+ this.removeConnection(connId);
349+ }
350+ }
351+ }
352+
353+ /**
354+ * Get all active user IDs
355+ */
356+ getActiveUserIds(): string[] {
357+ return Array.from(this.userConnections.keys());
358+ }
359+
360+ /**
361+ * Get connection statistics
362+ */
363+ getStats(): { totalConnections: number; uniqueUsers: number; queueStats: { rooms: number; totalMessages: number } } {
364+ return {
365+ totalConnections: this.connections.size,
366+ uniqueUsers: this.userConnections.size,
367+ queueStats: messageQueue.getStats()
368+ };
369+ }
370+}
371+
372+// Singleton instance
373+export const sseManager = new SSEManager();
A src/lib/sse/__tests__/MessageQueue.test.ts +156 -0
@@ -0,0 +1,156 @@
1+/**
2+ * Unit tests for MessageQueue
3+ */
4+
5+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6+import { MessageQueue } from '../MessageQueue';
7+
8+describe('MessageQueue', () => {
9+ let queue: MessageQueue;
10+
11+ beforeEach(() => {
12+ // Create a queue with short TTL for testing
13+ queue = new MessageQueue(1000, 10); // 1 second TTL, max 10 messages
14+ });
15+
16+ afterEach(() => {
17+ queue.stopCleanup();
18+ queue.clear();
19+ });
20+
21+ describe('enqueue', () => {
22+ it('should add a message to the queue and return an ID', () => {
23+ const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' });
24+
25+ expect(id).toBeDefined();
26+ expect(typeof id).toBe('string');
27+ expect(id.length).toBeGreaterThan(0);
28+ });
29+
30+ it('should store messages by room ID', () => {
31+ queue.enqueue('room1', 'chatMessage', { text: 'hello' });
32+ queue.enqueue('room2', 'chatMessage', { text: 'world' });
33+
34+ const room1Messages = queue.getMessages('room1');
35+ const room2Messages = queue.getMessages('room2');
36+
37+ expect(room1Messages).toHaveLength(1);
38+ expect(room2Messages).toHaveLength(1);
39+ expect(room1Messages[0].data).toEqual({ text: 'hello' });
40+ expect(room2Messages[0].data).toEqual({ text: 'world' });
41+ });
42+
43+ it('should enforce max queue size', () => {
44+ // Add more than max (10) messages
45+ for (let i = 0; i < 15; i++) {
46+ queue.enqueue('room1', 'chatMessage', { index: i });
47+ }
48+
49+ const messages = queue.getMessages('room1');
50+ expect(messages).toHaveLength(10);
51+
52+ // Should keep the newest messages
53+ expect(messages[0].data).toEqual({ index: 5 });
54+ expect(messages[9].data).toEqual({ index: 14 });
55+ });
56+ });
57+
58+ describe('getMessagesSince', () => {
59+ it('should return messages after a given ID', () => {
60+ const id1 = queue.enqueue('room1', 'chatMessage', { index: 1 });
61+ const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 });
62+ queue.enqueue('room1', 'chatMessage', { index: 3 });
63+
64+ const messagesSinceId1 = queue.getMessagesSince('room1', id1);
65+ expect(messagesSinceId1).toHaveLength(2);
66+ expect(messagesSinceId1[0].data).toEqual({ index: 2 });
67+ expect(messagesSinceId1[1].data).toEqual({ index: 3 });
68+
69+ const messagesSinceId2 = queue.getMessagesSince('room1', id2);
70+ expect(messagesSinceId2).toHaveLength(1);
71+ expect(messagesSinceId2[0].data).toEqual({ index: 3 });
72+ });
73+
74+ it('should return recent messages if ID not found', () => {
75+ for (let i = 0; i < 5; i++) {
76+ queue.enqueue('room1', 'chatMessage', { index: i });
77+ }
78+
79+ const messages = queue.getMessagesSince('room1', 'unknown-id');
80+ expect(messages).toHaveLength(5);
81+ });
82+
83+ it('should return empty array for non-existent room', () => {
84+ const messages = queue.getMessagesSince('nonexistent', 'some-id');
85+ expect(messages).toEqual([]);
86+ });
87+ });
88+
89+ describe('getLatestEventId', () => {
90+ it('should return the ID of the last message', () => {
91+ queue.enqueue('room1', 'chatMessage', { index: 1 });
92+ const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 });
93+
94+ expect(queue.getLatestEventId('room1')).toBe(id2);
95+ });
96+
97+ it('should return null for empty room', () => {
98+ expect(queue.getLatestEventId('nonexistent')).toBeNull();
99+ });
100+ });
101+
102+ describe('hasEventId', () => {
103+ it('should return true if event ID exists', () => {
104+ const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' });
105+ expect(queue.hasEventId('room1', id)).toBe(true);
106+ });
107+
108+ it('should return false if event ID does not exist', () => {
109+ expect(queue.hasEventId('room1', 'unknown')).toBe(false);
110+ });
111+ });
112+
113+ describe('cleanup', () => {
114+ it('should remove expired messages', async () => {
115+ vi.useFakeTimers();
116+
117+ queue.enqueue('room1', 'chatMessage', { text: 'old' });
118+
119+ // Fast-forward past TTL
120+ vi.advanceTimersByTime(2000);
121+
122+ const removed = queue.cleanup();
123+ expect(removed).toBe(1);
124+ expect(queue.getMessages('room1')).toHaveLength(0);
125+
126+ vi.useRealTimers();
127+ });
128+
129+ it('should keep non-expired messages', async () => {
130+ vi.useFakeTimers();
131+
132+ queue.enqueue('room1', 'chatMessage', { text: 'message' });
133+
134+ // Fast-forward but not past TTL
135+ vi.advanceTimersByTime(500);
136+
137+ const removed = queue.cleanup();
138+ expect(removed).toBe(0);
139+ expect(queue.getMessages('room1')).toHaveLength(1);
140+
141+ vi.useRealTimers();
142+ });
143+ });
144+
145+ describe('getStats', () => {
146+ it('should return correct statistics', () => {
147+ queue.enqueue('room1', 'chatMessage', { text: '1' });
148+ queue.enqueue('room1', 'chatMessage', { text: '2' });
149+ queue.enqueue('room2', 'chatMessage', { text: '3' });
150+
151+ const stats = queue.getStats();
152+ expect(stats.rooms).toBe(2);
153+ expect(stats.totalMessages).toBe(3);
154+ });
155+ });
156+});
@@ -0,0 +1,156 @@
1+/**
2+ * Unit tests for MessageQueue
3+ */
4+
5+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
6+import { MessageQueue } from '../MessageQueue';
7+
8+describe('MessageQueue', () => {
9+ let queue: MessageQueue;
10+
11+ beforeEach(() => {
12+ // Create a queue with short TTL for testing
13+ queue = new MessageQueue(1000, 10); // 1 second TTL, max 10 messages
14+ });
15+
16+ afterEach(() => {
17+ queue.stopCleanup();
18+ queue.clear();
19+ });
20+
21+ describe('enqueue', () => {
22+ it('should add a message to the queue and return an ID', () => {
23+ const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' });
24+
25+ expect(id).toBeDefined();
26+ expect(typeof id).toBe('string');
27+ expect(id.length).toBeGreaterThan(0);
28+ });
29+
30+ it('should store messages by room ID', () => {
31+ queue.enqueue('room1', 'chatMessage', { text: 'hello' });
32+ queue.enqueue('room2', 'chatMessage', { text: 'world' });
33+
34+ const room1Messages = queue.getMessages('room1');
35+ const room2Messages = queue.getMessages('room2');
36+
37+ expect(room1Messages).toHaveLength(1);
38+ expect(room2Messages).toHaveLength(1);
39+ expect(room1Messages[0].data).toEqual({ text: 'hello' });
40+ expect(room2Messages[0].data).toEqual({ text: 'world' });
41+ });
42+
43+ it('should enforce max queue size', () => {
44+ // Add more than max (10) messages
45+ for (let i = 0; i < 15; i++) {
46+ queue.enqueue('room1', 'chatMessage', { index: i });
47+ }
48+
49+ const messages = queue.getMessages('room1');
50+ expect(messages).toHaveLength(10);
51+
52+ // Should keep the newest messages
53+ expect(messages[0].data).toEqual({ index: 5 });
54+ expect(messages[9].data).toEqual({ index: 14 });
55+ });
56+ });
57+
58+ describe('getMessagesSince', () => {
59+ it('should return messages after a given ID', () => {
60+ const id1 = queue.enqueue('room1', 'chatMessage', { index: 1 });
61+ const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 });
62+ queue.enqueue('room1', 'chatMessage', { index: 3 });
63+
64+ const messagesSinceId1 = queue.getMessagesSince('room1', id1);
65+ expect(messagesSinceId1).toHaveLength(2);
66+ expect(messagesSinceId1[0].data).toEqual({ index: 2 });
67+ expect(messagesSinceId1[1].data).toEqual({ index: 3 });
68+
69+ const messagesSinceId2 = queue.getMessagesSince('room1', id2);
70+ expect(messagesSinceId2).toHaveLength(1);
71+ expect(messagesSinceId2[0].data).toEqual({ index: 3 });
72+ });
73+
74+ it('should return recent messages if ID not found', () => {
75+ for (let i = 0; i < 5; i++) {
76+ queue.enqueue('room1', 'chatMessage', { index: i });
77+ }
78+
79+ const messages = queue.getMessagesSince('room1', 'unknown-id');
80+ expect(messages).toHaveLength(5);
81+ });
82+
83+ it('should return empty array for non-existent room', () => {
84+ const messages = queue.getMessagesSince('nonexistent', 'some-id');
85+ expect(messages).toEqual([]);
86+ });
87+ });
88+
89+ describe('getLatestEventId', () => {
90+ it('should return the ID of the last message', () => {
91+ queue.enqueue('room1', 'chatMessage', { index: 1 });
92+ const id2 = queue.enqueue('room1', 'chatMessage', { index: 2 });
93+
94+ expect(queue.getLatestEventId('room1')).toBe(id2);
95+ });
96+
97+ it('should return null for empty room', () => {
98+ expect(queue.getLatestEventId('nonexistent')).toBeNull();
99+ });
100+ });
101+
102+ describe('hasEventId', () => {
103+ it('should return true if event ID exists', () => {
104+ const id = queue.enqueue('room1', 'chatMessage', { text: 'hello' });
105+ expect(queue.hasEventId('room1', id)).toBe(true);
106+ });
107+
108+ it('should return false if event ID does not exist', () => {
109+ expect(queue.hasEventId('room1', 'unknown')).toBe(false);
110+ });
111+ });
112+
113+ describe('cleanup', () => {
114+ it('should remove expired messages', async () => {
115+ vi.useFakeTimers();
116+
117+ queue.enqueue('room1', 'chatMessage', { text: 'old' });
118+
119+ // Fast-forward past TTL
120+ vi.advanceTimersByTime(2000);
121+
122+ const removed = queue.cleanup();
123+ expect(removed).toBe(1);
124+ expect(queue.getMessages('room1')).toHaveLength(0);
125+
126+ vi.useRealTimers();
127+ });
128+
129+ it('should keep non-expired messages', async () => {
130+ vi.useFakeTimers();
131+
132+ queue.enqueue('room1', 'chatMessage', { text: 'message' });
133+
134+ // Fast-forward but not past TTL
135+ vi.advanceTimersByTime(500);
136+
137+ const removed = queue.cleanup();
138+ expect(removed).toBe(0);
139+ expect(queue.getMessages('room1')).toHaveLength(1);
140+
141+ vi.useRealTimers();
142+ });
143+ });
144+
145+ describe('getStats', () => {
146+ it('should return correct statistics', () => {
147+ queue.enqueue('room1', 'chatMessage', { text: '1' });
148+ queue.enqueue('room1', 'chatMessage', { text: '2' });
149+ queue.enqueue('room2', 'chatMessage', { text: '3' });
150+
151+ const stats = queue.getStats();
152+ expect(stats.rooms).toBe(2);
153+ expect(stats.totalMessages).toBe(3);
154+ });
155+ });
156+});
A src/lib/sse/__tests__/SSEEventEmitter.test.ts +187 -0
@@ -0,0 +1,187 @@
1+/**
2+ * Unit tests for SSEEventEmitter
3+ */
4+
5+import { describe, it, expect, beforeEach, vi } from 'vitest';
6+
7+// We need to import the class, not the singleton for testing
8+// First, let's create a test-friendly version
9+
10+describe('SSEEventEmitter', () => {
11+ // Import the module fresh for each test
12+ let SSEEventEmitter: typeof import('../SSEEventEmitter');
13+
14+ beforeEach(async () => {
15+ // Clear module cache and re-import
16+ vi.resetModules();
17+ SSEEventEmitter = await import('../SSEEventEmitter');
18+ });
19+
20+ describe('emit', () => {
21+ it('should broadcast events to all listeners on the sse channel', () => {
22+ const { sseEmitter } = SSEEventEmitter;
23+ const listener = vi.fn();
24+
25+ sseEmitter.addListener('sse', listener);
26+
27+ sseEmitter.emit('chatMessage', {
28+ id: '123',
29+ chatRoomId: 'room1',
30+ senderId: 'user1',
31+ content: 'Hello',
32+ type: 'chat',
33+ timestamp: Date.now()
34+ });
35+
36+ expect(listener).toHaveBeenCalledTimes(1);
37+ expect(listener).toHaveBeenCalledWith(
38+ expect.objectContaining({
39+ type: 'chatMessage',
40+ data: expect.objectContaining({
41+ id: '123',
42+ content: 'Hello'
43+ })
44+ })
45+ );
46+ });
47+
48+ it('should include event ID when provided', () => {
49+ const { sseEmitter } = SSEEventEmitter;
50+ const listener = vi.fn();
51+
52+ sseEmitter.addListener('sse', listener);
53+
54+ sseEmitter.emit(
55+ 'chatMessage',
56+ {
57+ id: '123',
58+ chatRoomId: 'room1',
59+ senderId: 'user1',
60+ content: 'Hello',
61+ type: 'chat',
62+ timestamp: Date.now()
63+ },
64+ 'event-id-456'
65+ );
66+
67+ expect(listener).toHaveBeenCalledWith(
68+ expect.objectContaining({
69+ id: 'event-id-456'
70+ })
71+ );
72+ });
73+ });
74+
75+ describe('addListener', () => {
76+ it('should return a cleanup function', () => {
77+ const { sseEmitter } = SSEEventEmitter;
78+ const listener = vi.fn();
79+
80+ const cleanup = sseEmitter.addListener('sse', listener);
81+
82+ expect(typeof cleanup).toBe('function');
83+ expect(sseEmitter.getListenerCount('sse')).toBe(1);
84+
85+ cleanup();
86+ expect(sseEmitter.getListenerCount('sse')).toBe(0);
87+ });
88+
89+ it('should support multiple listeners', () => {
90+ const { sseEmitter } = SSEEventEmitter;
91+ const listener1 = vi.fn();
92+ const listener2 = vi.fn();
93+
94+ sseEmitter.addListener('sse', listener1);
95+ sseEmitter.addListener('sse', listener2);
96+
97+ sseEmitter.emit('buddyListUpdate', []);
98+
99+ expect(listener1).toHaveBeenCalledTimes(1);
100+ expect(listener2).toHaveBeenCalledTimes(1);
101+ });
102+ });
103+
104+ describe('removeListener', () => {
105+ it('should remove a specific listener', () => {
106+ const { sseEmitter } = SSEEventEmitter;
107+ const listener = vi.fn();
108+
109+ sseEmitter.addListener('sse', listener);
110+ expect(sseEmitter.getListenerCount('sse')).toBe(1);
111+
112+ const removed = sseEmitter.removeListener('sse', listener);
113+ expect(removed).toBe(true);
114+ expect(sseEmitter.getListenerCount('sse')).toBe(0);
115+ });
116+
117+ it('should return false for non-existent listener', () => {
118+ const { sseEmitter } = SSEEventEmitter;
119+ const listener = vi.fn();
120+
121+ const removed = sseEmitter.removeListener('sse', listener);
122+ expect(removed).toBe(false);
123+ });
124+ });
125+
126+ describe('removeAllListeners', () => {
127+ it('should remove all listeners for a channel', () => {
128+ const { sseEmitter } = SSEEventEmitter;
129+ const listener1 = vi.fn();
130+ const listener2 = vi.fn();
131+
132+ sseEmitter.addListener('sse', listener1);
133+ sseEmitter.addListener('sse', listener2);
134+ expect(sseEmitter.getListenerCount('sse')).toBe(2);
135+
136+ sseEmitter.removeAllListeners('sse');
137+ expect(sseEmitter.getListenerCount('sse')).toBe(0);
138+ });
139+
140+ it('should remove all listeners when no channel specified', () => {
141+ const { sseEmitter } = SSEEventEmitter;
142+ const listener = vi.fn();
143+
144+ sseEmitter.addListener('sse', listener);
145+ sseEmitter.addListener('other', listener);
146+
147+ sseEmitter.removeAllListeners();
148+ expect(sseEmitter.getListenerCount()).toBe(0);
149+ });
150+ });
151+
152+ describe('hasListeners', () => {
153+ it('should return true when channel has listeners', () => {
154+ const { sseEmitter } = SSEEventEmitter;
155+ const listener = vi.fn();
156+
157+ sseEmitter.addListener('sse', listener);
158+ expect(sseEmitter.hasListeners('sse')).toBe(true);
159+ });
160+
161+ it('should return false when channel has no listeners', () => {
162+ const { sseEmitter } = SSEEventEmitter;
163+ expect(sseEmitter.hasListeners('sse')).toBe(false);
164+ });
165+ });
166+
167+ describe('error handling', () => {
168+ it('should continue broadcasting even if a listener throws', () => {
169+ const { sseEmitter } = SSEEventEmitter;
170+ const badListener = vi.fn(() => {
171+ throw new Error('Listener error');
172+ });
173+ const goodListener = vi.fn();
174+
175+ sseEmitter.addListener('sse', badListener);
176+ sseEmitter.addListener('sse', goodListener);
177+
178+ // Should not throw
179+ expect(() => {
180+ sseEmitter.emit('buddyListUpdate', []);
181+ }).not.toThrow();
182+
183+ // Good listener should still be called
184+ expect(goodListener).toHaveBeenCalledTimes(1);
185+ });
186+ });
187+});
@@ -0,0 +1,187 @@
1+/**
2+ * Unit tests for SSEEventEmitter
3+ */
4+
5+import { describe, it, expect, beforeEach, vi } from 'vitest';
6+
7+// We need to import the class, not the singleton for testing
8+// First, let's create a test-friendly version
9+
10+describe('SSEEventEmitter', () => {
11+ // Import the module fresh for each test
12+ let SSEEventEmitter: typeof import('../SSEEventEmitter');
13+
14+ beforeEach(async () => {
15+ // Clear module cache and re-import
16+ vi.resetModules();
17+ SSEEventEmitter = await import('../SSEEventEmitter');
18+ });
19+
20+ describe('emit', () => {
21+ it('should broadcast events to all listeners on the sse channel', () => {
22+ const { sseEmitter } = SSEEventEmitter;
23+ const listener = vi.fn();
24+
25+ sseEmitter.addListener('sse', listener);
26+
27+ sseEmitter.emit('chatMessage', {
28+ id: '123',
29+ chatRoomId: 'room1',
30+ senderId: 'user1',
31+ content: 'Hello',
32+ type: 'chat',
33+ timestamp: Date.now()
34+ });
35+
36+ expect(listener).toHaveBeenCalledTimes(1);
37+ expect(listener).toHaveBeenCalledWith(
38+ expect.objectContaining({
39+ type: 'chatMessage',
40+ data: expect.objectContaining({
41+ id: '123',
42+ content: 'Hello'
43+ })
44+ })
45+ );
46+ });
47+
48+ it('should include event ID when provided', () => {
49+ const { sseEmitter } = SSEEventEmitter;
50+ const listener = vi.fn();
51+
52+ sseEmitter.addListener('sse', listener);
53+
54+ sseEmitter.emit(
55+ 'chatMessage',
56+ {
57+ id: '123',
58+ chatRoomId: 'room1',
59+ senderId: 'user1',
60+ content: 'Hello',
61+ type: 'chat',
62+ timestamp: Date.now()
63+ },
64+ 'event-id-456'
65+ );
66+
67+ expect(listener).toHaveBeenCalledWith(
68+ expect.objectContaining({
69+ id: 'event-id-456'
70+ })
71+ );
72+ });
73+ });
74+
75+ describe('addListener', () => {
76+ it('should return a cleanup function', () => {
77+ const { sseEmitter } = SSEEventEmitter;
78+ const listener = vi.fn();
79+
80+ const cleanup = sseEmitter.addListener('sse', listener);
81+
82+ expect(typeof cleanup).toBe('function');
83+ expect(sseEmitter.getListenerCount('sse')).toBe(1);
84+
85+ cleanup();
86+ expect(sseEmitter.getListenerCount('sse')).toBe(0);
87+ });
88+
89+ it('should support multiple listeners', () => {
90+ const { sseEmitter } = SSEEventEmitter;
91+ const listener1 = vi.fn();
92+ const listener2 = vi.fn();
93+
94+ sseEmitter.addListener('sse', listener1);
95+ sseEmitter.addListener('sse', listener2);
96+
97+ sseEmitter.emit('buddyListUpdate', []);
98+
99+ expect(listener1).toHaveBeenCalledTimes(1);
100+ expect(listener2).toHaveBeenCalledTimes(1);
101+ });
102+ });
103+
104+ describe('removeListener', () => {
105+ it('should remove a specific listener', () => {
106+ const { sseEmitter } = SSEEventEmitter;
107+ const listener = vi.fn();
108+
109+ sseEmitter.addListener('sse', listener);
110+ expect(sseEmitter.getListenerCount('sse')).toBe(1);
111+
112+ const removed = sseEmitter.removeListener('sse', listener);
113+ expect(removed).toBe(true);
114+ expect(sseEmitter.getListenerCount('sse')).toBe(0);
115+ });
116+
117+ it('should return false for non-existent listener', () => {
118+ const { sseEmitter } = SSEEventEmitter;
119+ const listener = vi.fn();
120+
121+ const removed = sseEmitter.removeListener('sse', listener);
122+ expect(removed).toBe(false);
123+ });
124+ });
125+
126+ describe('removeAllListeners', () => {
127+ it('should remove all listeners for a channel', () => {
128+ const { sseEmitter } = SSEEventEmitter;
129+ const listener1 = vi.fn();
130+ const listener2 = vi.fn();
131+
132+ sseEmitter.addListener('sse', listener1);
133+ sseEmitter.addListener('sse', listener2);
134+ expect(sseEmitter.getListenerCount('sse')).toBe(2);
135+
136+ sseEmitter.removeAllListeners('sse');
137+ expect(sseEmitter.getListenerCount('sse')).toBe(0);
138+ });
139+
140+ it('should remove all listeners when no channel specified', () => {
141+ const { sseEmitter } = SSEEventEmitter;
142+ const listener = vi.fn();
143+
144+ sseEmitter.addListener('sse', listener);
145+ sseEmitter.addListener('other', listener);
146+
147+ sseEmitter.removeAllListeners();
148+ expect(sseEmitter.getListenerCount()).toBe(0);
149+ });
150+ });
151+
152+ describe('hasListeners', () => {
153+ it('should return true when channel has listeners', () => {
154+ const { sseEmitter } = SSEEventEmitter;
155+ const listener = vi.fn();
156+
157+ sseEmitter.addListener('sse', listener);
158+ expect(sseEmitter.hasListeners('sse')).toBe(true);
159+ });
160+
161+ it('should return false when channel has no listeners', () => {
162+ const { sseEmitter } = SSEEventEmitter;
163+ expect(sseEmitter.hasListeners('sse')).toBe(false);
164+ });
165+ });
166+
167+ describe('error handling', () => {
168+ it('should continue broadcasting even if a listener throws', () => {
169+ const { sseEmitter } = SSEEventEmitter;
170+ const badListener = vi.fn(() => {
171+ throw new Error('Listener error');
172+ });
173+ const goodListener = vi.fn();
174+
175+ sseEmitter.addListener('sse', badListener);
176+ sseEmitter.addListener('sse', goodListener);
177+
178+ // Should not throw
179+ expect(() => {
180+ sseEmitter.emit('buddyListUpdate', []);
181+ }).not.toThrow();
182+
183+ // Good listener should still be called
184+ expect(goodListener).toHaveBeenCalledTimes(1);
185+ });
186+ });
187+});
A src/lib/sse/config.ts +53 -0
@@ -0,0 +1,53 @@
1+/**
2+ * Centralized SSE Configuration Constants
3+ *
4+ * All timing constants for SSE connection management are defined here
5+ * to ensure consistency between server and client implementations.
6+ */
7+
8+export const SSE_CONFIG = {
9+ // Server-side timing
10+ /** Heartbeat interval - server sends ping every 30 seconds */
11+ HEARTBEAT_INTERVAL: 30_000,
12+
13+ /** Connection timeout - mark user offline after 5 minutes of inactivity */
14+ CONNECTION_TIMEOUT: 5 * 60_000,
15+
16+ /** Stale connection check interval - cleanup every 60 seconds */
17+ STALE_CHECK_INTERVAL: 60_000,
18+
19+ /** Message queue TTL - keep messages for 5 minutes for catch-up */
20+ MESSAGE_QUEUE_TTL: 5 * 60_000,
21+
22+ /** Maximum messages to queue per room */
23+ MESSAGE_QUEUE_MAX_SIZE: 1000,
24+
25+ /** Buddy list broadcast interval */
26+ BUDDY_LIST_BROADCAST_INTERVAL: 10_000,
27+
28+ // Client-side timing
29+ /** Initial reconnect delay - 1 second */
30+ INITIAL_RECONNECT_DELAY: 1_000,
31+
32+ /** Maximum reconnect delay - 60 seconds cap */
33+ MAX_RECONNECT_DELAY: 60_000,
34+
35+ /** Heartbeat timeout - 45 seconds (1.5x server interval) */
36+ HEARTBEAT_TIMEOUT: 45_000,
37+
38+ // Shared timing
39+ /** Idle timeout - mark as idle after 2 minutes */
40+ IDLE_TIMEOUT: 2 * 60_000,
41+
42+ /** Status update interval */
43+ STATUS_UPDATE_INTERVAL: 30_000,
44+
45+ /** EventSource retry hint (ms) - sent to client */
46+ RETRY_INTERVAL: 3_000
47+} as const;
48+
49+/** SSE Event Types */
50+export type SSEEventType = 'chatMessage' | 'userStatusUpdate' | 'buddyListUpdate' | 'heartbeat';
51+
52+/** Connection status */
53+export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
@@ -0,0 +1,53 @@
1+/**
2+ * Centralized SSE Configuration Constants
3+ *
4+ * All timing constants for SSE connection management are defined here
5+ * to ensure consistency between server and client implementations.
6+ */
7+
8+export const SSE_CONFIG = {
9+ // Server-side timing
10+ /** Heartbeat interval - server sends ping every 30 seconds */
11+ HEARTBEAT_INTERVAL: 30_000,
12+
13+ /** Connection timeout - mark user offline after 5 minutes of inactivity */
14+ CONNECTION_TIMEOUT: 5 * 60_000,
15+
16+ /** Stale connection check interval - cleanup every 60 seconds */
17+ STALE_CHECK_INTERVAL: 60_000,
18+
19+ /** Message queue TTL - keep messages for 5 minutes for catch-up */
20+ MESSAGE_QUEUE_TTL: 5 * 60_000,
21+
22+ /** Maximum messages to queue per room */
23+ MESSAGE_QUEUE_MAX_SIZE: 1000,
24+
25+ /** Buddy list broadcast interval */
26+ BUDDY_LIST_BROADCAST_INTERVAL: 10_000,
27+
28+ // Client-side timing
29+ /** Initial reconnect delay - 1 second */
30+ INITIAL_RECONNECT_DELAY: 1_000,
31+
32+ /** Maximum reconnect delay - 60 seconds cap */
33+ MAX_RECONNECT_DELAY: 60_000,
34+
35+ /** Heartbeat timeout - 45 seconds (1.5x server interval) */
36+ HEARTBEAT_TIMEOUT: 45_000,
37+
38+ // Shared timing
39+ /** Idle timeout - mark as idle after 2 minutes */
40+ IDLE_TIMEOUT: 2 * 60_000,
41+
42+ /** Status update interval */
43+ STATUS_UPDATE_INTERVAL: 30_000,
44+
45+ /** EventSource retry hint (ms) - sent to client */
46+ RETRY_INTERVAL: 3_000
47+} as const;
48+
49+/** SSE Event Types */
50+export type SSEEventType = 'chatMessage' | 'userStatusUpdate' | 'buddyListUpdate' | 'heartbeat';
51+
52+/** Connection status */
53+export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close