detached 82f3e96

Merge pull request #56 from kitpdr/feat/sse-refactor

feat: refactor SSE infrastructure with modular client architecture
commit
82f3e96703f7aa8ed2cc377665157f93fbd402ed
author
KiT · · 2025-12-15 12:53 UTC
committer
GitHub · · 2025-12-15 12:53 UTC
merge of
44acee2 + e52fcc0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
14 files · +1988 -525
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 +322 -0
@@ -0,0 +1,322 @@
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(`[SSEClient] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`);
245+
246+ this.reconnectTimer = setTimeout(() => {
247+ this.connect();
248+ }, delay);
249+ }
250+
251+ /**
252+ * Calculate reconnection delay with exponential backoff and jitter
253+ * Never gives up - always tries to reconnect
254+ */
255+ private calculateBackoff(): number {
256+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
257+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
258+
259+ // Exponential backoff: 2^attempts * base, capped at max
260+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts), max);
261+
262+ // Add random jitter (0-25% of delay) to prevent thundering herd
263+ const jitter = exponentialDelay * Math.random() * 0.25;
264+
265+ return Math.floor(exponentialDelay + jitter);
266+ }
267+
268+ /**
269+ * Get the estimated next reconnect delay in seconds
270+ * Returns null if not reconnecting
271+ */
272+ getReconnectDelay(): number | null {
273+ if (this._status !== 'reconnecting') {
274+ return null;
275+ }
276+ // Calculate the expected delay for the current attempt
277+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
278+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
279+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts - 1), max);
280+ return Math.ceil(exponentialDelay / 1000); // Convert to seconds
281+ }
282+
283+ /**
284+ * Clean up resources
285+ */
286+ private cleanup(): void {
287+ this.clearHeartbeatTimer();
288+
289+ if (this.reconnectTimer) {
290+ clearTimeout(this.reconnectTimer);
291+ this.reconnectTimer = null;
292+ }
293+
294+ if (this.eventSource) {
295+ this.eventSource.close();
296+ this.eventSource = null;
297+ }
298+ }
299+
300+ /**
301+ * Set connection status and notify listener
302+ */
303+ private setStatus(status: ConnectionStatus): void {
304+ this._status = status;
305+ this.config.onStatusChange?.(status);
306+ }
307+
308+ /**
309+ * Destroy the client completely
310+ */
311+ destroy(): void {
312+ console.debug('[SSEClient] Destroying');
313+ this.disconnect();
314+ }
315+}
316+
317+/**
318+ * Factory function to create an SSE client
319+ */
320+export function createSSEClient(config: SSEClientConfig): SSEClient {
321+ return new SSEClient(config);
322+}
@@ -0,0 +1,322 @@
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(`[SSEClient] Reconnecting in ${delay}ms (attempt ${this._reconnectAttempts})`);
245+
246+ this.reconnectTimer = setTimeout(() => {
247+ this.connect();
248+ }, delay);
249+ }
250+
251+ /**
252+ * Calculate reconnection delay with exponential backoff and jitter
253+ * Never gives up - always tries to reconnect
254+ */
255+ private calculateBackoff(): number {
256+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
257+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
258+
259+ // Exponential backoff: 2^attempts * base, capped at max
260+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts), max);
261+
262+ // Add random jitter (0-25% of delay) to prevent thundering herd
263+ const jitter = exponentialDelay * Math.random() * 0.25;
264+
265+ return Math.floor(exponentialDelay + jitter);
266+ }
267+
268+ /**
269+ * Get the estimated next reconnect delay in seconds
270+ * Returns null if not reconnecting
271+ */
272+ getReconnectDelay(): number | null {
273+ if (this._status !== 'reconnecting') {
274+ return null;
275+ }
276+ // Calculate the expected delay for the current attempt
277+ const base = SSE_CONFIG.INITIAL_RECONNECT_DELAY;
278+ const max = SSE_CONFIG.MAX_RECONNECT_DELAY;
279+ const exponentialDelay = Math.min(base * Math.pow(2, this._reconnectAttempts - 1), max);
280+ return Math.ceil(exponentialDelay / 1000); // Convert to seconds
281+ }
282+
283+ /**
284+ * Clean up resources
285+ */
286+ private cleanup(): void {
287+ this.clearHeartbeatTimer();
288+
289+ if (this.reconnectTimer) {
290+ clearTimeout(this.reconnectTimer);
291+ this.reconnectTimer = null;
292+ }
293+
294+ if (this.eventSource) {
295+ this.eventSource.close();
296+ this.eventSource = null;
297+ }
298+ }
299+
300+ /**
301+ * Set connection status and notify listener
302+ */
303+ private setStatus(status: ConnectionStatus): void {
304+ this._status = status;
305+ this.config.onStatusChange?.(status);
306+ }
307+
308+ /**
309+ * Destroy the client completely
310+ */
311+ destroy(): void {
312+ console.debug('[SSEClient] Destroying');
313+ this.disconnect();
314+ }
315+}
316+
317+/**
318+ * Factory function to create an SSE client
319+ */
320+export function createSSEClient(config: SSEClientConfig): SSEClient {
321+ return new SSEClient(config);
322+}
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 +381 -0
@@ -0,0 +1,381 @@
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): {
140+ removed: boolean;
141+ isLastConnection: boolean;
142+ userId: string | null;
143+ } {
144+ const connection = this.connections.get(connectionId);
145+ if (!connection) {
146+ return { removed: false, isLastConnection: false, userId: null };
147+ }
148+
149+ const { userId } = connection;
150+ this.connections.delete(connectionId);
151+
152+ // Update user connections map
153+ const userConns = this.userConnections.get(userId);
154+ if (userConns) {
155+ userConns.delete(connectionId);
156+ if (userConns.size === 0) {
157+ this.userConnections.delete(userId);
158+ }
159+ }
160+
161+ const isLastConnection = !userConns || userConns.size === 0;
162+
163+ log.info('Connection removed', {
164+ connectionId: connectionId.slice(0, 8),
165+ userId: userId.slice(0, 8),
166+ isLastConnection,
167+ remainingConnections: this.connections.size
168+ });
169+
170+ return { removed: true, isLastConnection, userId };
171+ }
172+
173+ /**
174+ * Get a connection by ID
175+ */
176+ getConnection(connectionId: string): SSEConnection | undefined {
177+ return this.connections.get(connectionId);
178+ }
179+
180+ /**
181+ * Check if a user has any active connections
182+ */
183+ hasUserConnection(userId: string): boolean {
184+ const userConns = this.userConnections.get(userId);
185+ return userConns !== undefined && userConns.size > 0;
186+ }
187+
188+ /**
189+ * Get all connection IDs for a user
190+ */
191+ getUserConnectionIds(userId: string): string[] {
192+ const userConns = this.userConnections.get(userId);
193+ return userConns ? Array.from(userConns) : [];
194+ }
195+
196+ /**
197+ * Update activity timestamp for a connection
198+ */
199+ updateActivity(connectionId: string): void {
200+ const connection = this.connections.get(connectionId);
201+ if (connection) {
202+ connection.lastActivity = Date.now();
203+ }
204+ }
205+
206+ /**
207+ * Acknowledge heartbeat for a connection
208+ */
209+ acknowledgeHeartbeat(connectionId: string): boolean {
210+ const connection = this.connections.get(connectionId);
211+ if (connection) {
212+ connection.lastHeartbeat = Date.now();
213+ connection.lastActivity = Date.now();
214+ log.debug('Heartbeat acknowledged', { connectionId: connectionId.slice(0, 8) });
215+ return true;
216+ }
217+ return false;
218+ }
219+
220+ /**
221+ * Send an event to a specific connection
222+ */
223+ sendToConnection(connectionId: string, event: SSEEvent, eventId?: string): boolean {
224+ const connection = this.connections.get(connectionId);
225+ if (!connection || !connection.controller) {
226+ return false;
227+ }
228+
229+ try {
230+ const id = eventId ?? crypto.randomUUID();
231+ const payload = this.formatSSEEvent(event, id);
232+ connection.controller.enqueue(this.encoder.encode(payload));
233+
234+ // Queue for catch-up
235+ messageQueue.enqueue(connection.roomId, event.type, event.data);
236+
237+ return true;
238+ } catch (error) {
239+ log.error('Error sending to connection', { connectionId: connectionId.slice(0, 8), error });
240+ return false;
241+ }
242+ }
243+
244+ /**
245+ * Send an event to a specific user (all their connections)
246+ */
247+ sendToUser(userId: string, event: SSEEvent): number {
248+ const connectionIds = this.getUserConnectionIds(userId);
249+ let sent = 0;
250+ for (const connId of connectionIds) {
251+ if (this.sendToConnection(connId, event)) {
252+ sent++;
253+ }
254+ }
255+ return sent;
256+ }
257+
258+ /**
259+ * Broadcast an event to all connections
260+ * Uses the event emitter for distribution
261+ */
262+ broadcast<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], roomId?: string): void {
263+ const eventId = crypto.randomUUID();
264+
265+ // Queue the message first
266+ if (roomId) {
267+ messageQueue.enqueue(roomId, type, data);
268+ }
269+
270+ // Emit through the event emitter (listeners on SSE endpoint will pick this up)
271+ sseEmitter.emit(type, data, eventId);
272+ }
273+
274+ /**
275+ * Get messages since a specific event ID (for catch-up)
276+ */
277+ getMessagesSince(roomId: string, lastEventId: string) {
278+ return messageQueue.getMessagesSince(roomId, lastEventId);
279+ }
280+
281+ /**
282+ * Format an SSE event for transmission
283+ */
284+ formatSSEEvent(event: SSEEvent, eventId: string): string {
285+ const lines = [
286+ `id: ${eventId}`,
287+ `event: ${event.type}`,
288+ `data: ${JSON.stringify(event.data)}`,
289+ `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
290+ '',
291+ ''
292+ ];
293+ return lines.join('\n');
294+ }
295+
296+ /**
297+ * Format a keep-alive comment
298+ */
299+ formatKeepAlive(): string {
300+ return ':\n\n';
301+ }
302+
303+ /**
304+ * Send heartbeats to all connections
305+ */
306+ private sendHeartbeats(): void {
307+ const now = Date.now();
308+ let sent = 0;
309+
310+ for (const [connectionId, connection] of this.connections) {
311+ if (connection.controller) {
312+ try {
313+ const heartbeatEvent: SSEEvent<'heartbeat'> = {
314+ type: 'heartbeat',
315+ data: {
316+ connectionId,
317+ timestamp: now,
318+ serverTime: new Date(now).toISOString()
319+ }
320+ };
321+ const payload = this.formatSSEEvent(heartbeatEvent, crypto.randomUUID());
322+ connection.controller.enqueue(this.encoder.encode(payload));
323+ sent++;
324+ } catch {
325+ // Connection might be closed, will be cleaned up later
326+ }
327+ }
328+ }
329+
330+ if (sent > 0) {
331+ log.debug('Heartbeats sent', { count: sent });
332+ }
333+ }
334+
335+ /**
336+ * Clean up stale connections
337+ */
338+ private cleanupStaleConnections(): void {
339+ const now = Date.now();
340+ const staleConnections: string[] = [];
341+
342+ for (const [connectionId, connection] of this.connections) {
343+ const timeSinceHeartbeat = now - connection.lastHeartbeat;
344+ if (timeSinceHeartbeat > SSE_CONFIG.HEARTBEAT_TIMEOUT) {
345+ staleConnections.push(connectionId);
346+ }
347+ }
348+
349+ if (staleConnections.length > 0) {
350+ log.info('Cleaning up stale connections', { count: staleConnections.length });
351+ for (const connId of staleConnections) {
352+ this.removeConnection(connId);
353+ }
354+ }
355+ }
356+
357+ /**
358+ * Get all active user IDs
359+ */
360+ getActiveUserIds(): string[] {
361+ return Array.from(this.userConnections.keys());
362+ }
363+
364+ /**
365+ * Get connection statistics
366+ */
367+ getStats(): {
368+ totalConnections: number;
369+ uniqueUsers: number;
370+ queueStats: { rooms: number; totalMessages: number };
371+ } {
372+ return {
373+ totalConnections: this.connections.size,
374+ uniqueUsers: this.userConnections.size,
375+ queueStats: messageQueue.getStats()
376+ };
377+ }
378+}
379+
380+// Singleton instance
381+export const sseManager = new SSEManager();
@@ -0,0 +1,381 @@
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): {
140+ removed: boolean;
141+ isLastConnection: boolean;
142+ userId: string | null;
143+ } {
144+ const connection = this.connections.get(connectionId);
145+ if (!connection) {
146+ return { removed: false, isLastConnection: false, userId: null };
147+ }
148+
149+ const { userId } = connection;
150+ this.connections.delete(connectionId);
151+
152+ // Update user connections map
153+ const userConns = this.userConnections.get(userId);
154+ if (userConns) {
155+ userConns.delete(connectionId);
156+ if (userConns.size === 0) {
157+ this.userConnections.delete(userId);
158+ }
159+ }
160+
161+ const isLastConnection = !userConns || userConns.size === 0;
162+
163+ log.info('Connection removed', {
164+ connectionId: connectionId.slice(0, 8),
165+ userId: userId.slice(0, 8),
166+ isLastConnection,
167+ remainingConnections: this.connections.size
168+ });
169+
170+ return { removed: true, isLastConnection, userId };
171+ }
172+
173+ /**
174+ * Get a connection by ID
175+ */
176+ getConnection(connectionId: string): SSEConnection | undefined {
177+ return this.connections.get(connectionId);
178+ }
179+
180+ /**
181+ * Check if a user has any active connections
182+ */
183+ hasUserConnection(userId: string): boolean {
184+ const userConns = this.userConnections.get(userId);
185+ return userConns !== undefined && userConns.size > 0;
186+ }
187+
188+ /**
189+ * Get all connection IDs for a user
190+ */
191+ getUserConnectionIds(userId: string): string[] {
192+ const userConns = this.userConnections.get(userId);
193+ return userConns ? Array.from(userConns) : [];
194+ }
195+
196+ /**
197+ * Update activity timestamp for a connection
198+ */
199+ updateActivity(connectionId: string): void {
200+ const connection = this.connections.get(connectionId);
201+ if (connection) {
202+ connection.lastActivity = Date.now();
203+ }
204+ }
205+
206+ /**
207+ * Acknowledge heartbeat for a connection
208+ */
209+ acknowledgeHeartbeat(connectionId: string): boolean {
210+ const connection = this.connections.get(connectionId);
211+ if (connection) {
212+ connection.lastHeartbeat = Date.now();
213+ connection.lastActivity = Date.now();
214+ log.debug('Heartbeat acknowledged', { connectionId: connectionId.slice(0, 8) });
215+ return true;
216+ }
217+ return false;
218+ }
219+
220+ /**
221+ * Send an event to a specific connection
222+ */
223+ sendToConnection(connectionId: string, event: SSEEvent, eventId?: string): boolean {
224+ const connection = this.connections.get(connectionId);
225+ if (!connection || !connection.controller) {
226+ return false;
227+ }
228+
229+ try {
230+ const id = eventId ?? crypto.randomUUID();
231+ const payload = this.formatSSEEvent(event, id);
232+ connection.controller.enqueue(this.encoder.encode(payload));
233+
234+ // Queue for catch-up
235+ messageQueue.enqueue(connection.roomId, event.type, event.data);
236+
237+ return true;
238+ } catch (error) {
239+ log.error('Error sending to connection', { connectionId: connectionId.slice(0, 8), error });
240+ return false;
241+ }
242+ }
243+
244+ /**
245+ * Send an event to a specific user (all their connections)
246+ */
247+ sendToUser(userId: string, event: SSEEvent): number {
248+ const connectionIds = this.getUserConnectionIds(userId);
249+ let sent = 0;
250+ for (const connId of connectionIds) {
251+ if (this.sendToConnection(connId, event)) {
252+ sent++;
253+ }
254+ }
255+ return sent;
256+ }
257+
258+ /**
259+ * Broadcast an event to all connections
260+ * Uses the event emitter for distribution
261+ */
262+ broadcast<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T], roomId?: string): void {
263+ const eventId = crypto.randomUUID();
264+
265+ // Queue the message first
266+ if (roomId) {
267+ messageQueue.enqueue(roomId, type, data);
268+ }
269+
270+ // Emit through the event emitter (listeners on SSE endpoint will pick this up)
271+ sseEmitter.emit(type, data, eventId);
272+ }
273+
274+ /**
275+ * Get messages since a specific event ID (for catch-up)
276+ */
277+ getMessagesSince(roomId: string, lastEventId: string) {
278+ return messageQueue.getMessagesSince(roomId, lastEventId);
279+ }
280+
281+ /**
282+ * Format an SSE event for transmission
283+ */
284+ formatSSEEvent(event: SSEEvent, eventId: string): string {
285+ const lines = [
286+ `id: ${eventId}`,
287+ `event: ${event.type}`,
288+ `data: ${JSON.stringify(event.data)}`,
289+ `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
290+ '',
291+ ''
292+ ];
293+ return lines.join('\n');
294+ }
295+
296+ /**
297+ * Format a keep-alive comment
298+ */
299+ formatKeepAlive(): string {
300+ return ':\n\n';
301+ }
302+
303+ /**
304+ * Send heartbeats to all connections
305+ */
306+ private sendHeartbeats(): void {
307+ const now = Date.now();
308+ let sent = 0;
309+
310+ for (const [connectionId, connection] of this.connections) {
311+ if (connection.controller) {
312+ try {
313+ const heartbeatEvent: SSEEvent<'heartbeat'> = {
314+ type: 'heartbeat',
315+ data: {
316+ connectionId,
317+ timestamp: now,
318+ serverTime: new Date(now).toISOString()
319+ }
320+ };
321+ const payload = this.formatSSEEvent(heartbeatEvent, crypto.randomUUID());
322+ connection.controller.enqueue(this.encoder.encode(payload));
323+ sent++;
324+ } catch {
325+ // Connection might be closed, will be cleaned up later
326+ }
327+ }
328+ }
329+
330+ if (sent > 0) {
331+ log.debug('Heartbeats sent', { count: sent });
332+ }
333+ }
334+
335+ /**
336+ * Clean up stale connections
337+ */
338+ private cleanupStaleConnections(): void {
339+ const now = Date.now();
340+ const staleConnections: string[] = [];
341+
342+ for (const [connectionId, connection] of this.connections) {
343+ const timeSinceHeartbeat = now - connection.lastHeartbeat;
344+ if (timeSinceHeartbeat > SSE_CONFIG.HEARTBEAT_TIMEOUT) {
345+ staleConnections.push(connectionId);
346+ }
347+ }
348+
349+ if (staleConnections.length > 0) {
350+ log.info('Cleaning up stale connections', { count: staleConnections.length });
351+ for (const connId of staleConnections) {
352+ this.removeConnection(connId);
353+ }
354+ }
355+ }
356+
357+ /**
358+ * Get all active user IDs
359+ */
360+ getActiveUserIds(): string[] {
361+ return Array.from(this.userConnections.keys());
362+ }
363+
364+ /**
365+ * Get connection statistics
366+ */
367+ getStats(): {
368+ totalConnections: number;
369+ uniqueUsers: number;
370+ queueStats: { rooms: number; totalMessages: number };
371+ } {
372+ return {
373+ totalConnections: this.connections.size,
374+ uniqueUsers: this.userConnections.size,
375+ queueStats: messageQueue.getStats()
376+ };
377+ }
378+}
379+
380+// Singleton instance
381+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';
D src/lib/sseConnectionTracker.ts +0 -90
@@ -1,90 +0,0 @@
1-import { createLogger } from './utils/logger.server';
2-
3-const log = createLogger('sse-connection-tracker');
4-
5-interface ConnectionInfo {
6- userId: string;
7- connectedAt: number;
8- lastActivity: number;
9-}
10-
11-class SSEConnectionTracker {
12- private connections = new Map<string, ConnectionInfo>();
13-
14- addConnection(userId: string): boolean {
15- const existing = this.connections.get(userId);
16- if (existing) {
17- log.debug('User already has active connection', { userId });
18- // Update last activity but don't trigger status update
19- existing.lastActivity = Date.now();
20- return false; // Connection already exists
21- }
22-
23- this.connections.set(userId, {
24- userId,
25- connectedAt: Date.now(),
26- lastActivity: Date.now()
27- });
28-
29- log.debug('New SSE connection tracked', {
30- userId,
31- totalConnections: this.connections.size
32- });
33- return true; // New connection
34- }
35-
36- removeConnection(userId: string): boolean {
37- const deleted = this.connections.delete(userId);
38- if (deleted) {
39- log.debug('SSE connection removed', {
40- userId,
41- remainingConnections: this.connections.size
42- });
43- }
44- return deleted;
45- }
46-
47- hasConnection(userId: string): boolean {
48- return this.connections.has(userId);
49- }
50-
51- updateActivity(userId: string): void {
52- const connection = this.connections.get(userId);
53- if (connection) {
54- connection.lastActivity = Date.now();
55- }
56- }
57-
58- getActiveUserIds(): string[] {
59- return Array.from(this.connections.keys());
60- }
61-
62- getConnectionCount(): number {
63- return this.connections.size;
64- }
65-
66- // Clean up stale connections (e.g., if server didn't properly close them)
67- cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] {
68- const now = Date.now();
69- const staleUserIds: string[] = [];
70-
71- for (const [userId, info] of this.connections.entries()) {
72- if (now - info.lastActivity > maxInactivityMs) {
73- this.connections.delete(userId);
74- staleUserIds.push(userId);
75- }
76- }
77-
78- if (staleUserIds.length > 0) {
79- log.info('Cleaned up stale connections', {
80- count: staleUserIds.length,
81- userIds: staleUserIds
82- });
83- }
84-
85- return staleUserIds;
86- }
87-}
88-
89-// Export singleton instance
90-export const sseConnectionTracker = new SSEConnectionTracker();
@@ -1,90 +0,0 @@
1-import { createLogger } from './utils/logger.server';
2-
3-const log = createLogger('sse-connection-tracker');
4-
5-interface ConnectionInfo {
6- userId: string;
7- connectedAt: number;
8- lastActivity: number;
9-}
10-
11-class SSEConnectionTracker {
12- private connections = new Map<string, ConnectionInfo>();
13-
14- addConnection(userId: string): boolean {
15- const existing = this.connections.get(userId);
16- if (existing) {
17- log.debug('User already has active connection', { userId });
18- // Update last activity but don't trigger status update
19- existing.lastActivity = Date.now();
20- return false; // Connection already exists
21- }
22-
23- this.connections.set(userId, {
24- userId,
25- connectedAt: Date.now(),
26- lastActivity: Date.now()
27- });
28-
29- log.debug('New SSE connection tracked', {
30- userId,
31- totalConnections: this.connections.size
32- });
33- return true; // New connection
34- }
35-
36- removeConnection(userId: string): boolean {
37- const deleted = this.connections.delete(userId);
38- if (deleted) {
39- log.debug('SSE connection removed', {
40- userId,
41- remainingConnections: this.connections.size
42- });
43- }
44- return deleted;
45- }
46-
47- hasConnection(userId: string): boolean {
48- return this.connections.has(userId);
49- }
50-
51- updateActivity(userId: string): void {
52- const connection = this.connections.get(userId);
53- if (connection) {
54- connection.lastActivity = Date.now();
55- }
56- }
57-
58- getActiveUserIds(): string[] {
59- return Array.from(this.connections.keys());
60- }
61-
62- getConnectionCount(): number {
63- return this.connections.size;
64- }
65-
66- // Clean up stale connections (e.g., if server didn't properly close them)
67- cleanupStaleConnections(maxInactivityMs: number = 5 * 60 * 1000): string[] {
68- const now = Date.now();
69- const staleUserIds: string[] = [];
70-
71- for (const [userId, info] of this.connections.entries()) {
72- if (now - info.lastActivity > maxInactivityMs) {
73- this.connections.delete(userId);
74- staleUserIds.push(userId);
75- }
76- }
77-
78- if (staleUserIds.length > 0) {
79- log.info('Cleaned up stale connections', {
80- count: staleUserIds.length,
81- userIds: staleUserIds
82- });
83- }
84-
85- return staleUserIds;
86- }
87-}
88-
89-// Export singleton instance
90-export const sseConnectionTracker = new SSEConnectionTracker();
D src/lib/sseEmitter.ts +0 -65
@@ -1,65 +0,0 @@
1-type SSEEventData = {
2- type: string;
3- data: unknown;
4-};
5-
6-class CustomEventEmitter {
7- private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map();
8- private maxListeners: number = 100;
9-
10- constructor() {
11- console.log('[SSE-Emitter] Initialized');
12- }
13-
14- emit(event: string, data: SSEEventData) {
15- const eventListeners = this.listeners.get(event);
16- if (eventListeners) {
17- // Only log non-sensitive event types
18- if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) {
19- console.log('[SSE-Emitter] Broadcasting event:', {
20- type: data.type,
21- listenersCount: eventListeners.length
22- });
23- }
24- eventListeners.forEach((listener) => listener(data));
25- }
26- }
27-
28- addListener(event: string, listener: (data: SSEEventData) => void) {
29- if (!this.listeners.has(event)) {
30- this.listeners.set(event, []);
31- }
32- const eventListeners = this.listeners.get(event)!;
33-
34- if (eventListeners.length >= this.maxListeners) {
35- console.log(
36- `[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})`
37- );
38- }
39-
40- eventListeners.push(listener);
41- console.log('[SSE-Emitter] New listener added:', {
42- event,
43- totalListeners: eventListeners.length
44- });
45- }
46-
47- removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) {
48- const eventListeners = this.listeners.get(event);
49- if (eventListeners) {
50- const newListeners = eventListeners.filter((listener) => listener !== listenerToRemove);
51- this.listeners.set(event, newListeners);
52- console.log('[SSE-Emitter] Listener removed:', {
53- event,
54- remainingListeners: newListeners.length
55- });
56- }
57- }
58-
59- setMaxListeners(n: number) {
60- this.maxListeners = n;
61- console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n });
62- }
63-}
64-
65-export const sseEmitter = new CustomEventEmitter();
@@ -1,65 +0,0 @@
1-type SSEEventData = {
2- type: string;
3- data: unknown;
4-};
5-
6-class CustomEventEmitter {
7- private listeners: Map<string, Array<(data: SSEEventData) => void>> = new Map();
8- private maxListeners: number = 100;
9-
10- constructor() {
11- console.log('[SSE-Emitter] Initialized');
12- }
13-
14- emit(event: string, data: SSEEventData) {
15- const eventListeners = this.listeners.get(event);
16- if (eventListeners) {
17- // Only log non-sensitive event types
18- if (!['userStatusUpdate', 'chatMessage'].includes(data.type)) {
19- console.log('[SSE-Emitter] Broadcasting event:', {
20- type: data.type,
21- listenersCount: eventListeners.length
22- });
23- }
24- eventListeners.forEach((listener) => listener(data));
25- }
26- }
27-
28- addListener(event: string, listener: (data: SSEEventData) => void) {
29- if (!this.listeners.has(event)) {
30- this.listeners.set(event, []);
31- }
32- const eventListeners = this.listeners.get(event)!;
33-
34- if (eventListeners.length >= this.maxListeners) {
35- console.log(
36- `[SSE-Emitter] Warning: Event '${event}' has exceeded the maximum number of listeners (${this.maxListeners})`
37- );
38- }
39-
40- eventListeners.push(listener);
41- console.log('[SSE-Emitter] New listener added:', {
42- event,
43- totalListeners: eventListeners.length
44- });
45- }
46-
47- removeListener(event: string, listenerToRemove: (data: SSEEventData) => void) {
48- const eventListeners = this.listeners.get(event);
49- if (eventListeners) {
50- const newListeners = eventListeners.filter((listener) => listener !== listenerToRemove);
51- this.listeners.set(event, newListeners);
52- console.log('[SSE-Emitter] Listener removed:', {
53- event,
54- remainingListeners: newListeners.length
55- });
56- }
57- }
58-
59- setMaxListeners(n: number) {
60- this.maxListeners = n;
61- console.log('[SSE-Emitter] Max listeners updated:', { newLimit: n });
62- }
63-}
64-
65-export const sseEmitter = new CustomEventEmitter();
M src/lib/states/chat.svelte.ts +194 -215
@@ -1,3 +1,10 @@
1+/**
2+ * Chat State Management
3+ *
4+ * Central state manager for chat functionality using Svelte 5 runes.
5+ * Handles SSE connections, messages, and user state.
6+ */
7+
18 import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';
29 import { createSafeUser } from '../types/chat';
310 import type {
@@ -8,38 +15,53 @@ import type {
815 import type { TextStyle } from '../types/text-formatting';
916 import { invalidate } from '$app/navigation';
1017 import { env } from '$env/dynamic/public';
18+import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte';
19+import type { SSEEventMap } from '../sse/SSEEventEmitter';
20+import type { ConnectionStatus } from '../sse/config';
1121
1222 // Use the public environment variable with a fallback
1323 const DEFAULT_CHAT_ROOM_ID =
1424 env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
1525
1626 class ChatState {
27+ // User and message state
1728 private users = $state<SafeUser[]>([]);
1829 private messages = $state<Message[]>([]);
1930 private currentUser = $state<SafeUser | null>(null);
20- private eventSource: EventSource | null = null;
21- private reconnectAttempts = 0;
22- private maxReconnectAttempts = 5;
23- private reconnectDelay = 1000; // Start with 1 second
24- private isInitializing = $state(false);
2531 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
2632 private userCache = $state<Record<string, SafeUser>>({});
27- private sseError = $state<string | null>(null);
28- private sseRetryAfter = $state<number | null>(null);
29- private connectionTimeout: ReturnType<typeof setTimeout> | null = null;
30- private isReconnecting = $state(false);
33+ private hasMoreMessages = $state(false);
34+
35+ // SSE Client
36+ private sseClient: SSEClient | null = null;
37+
38+ // Connection state
39+ private _connectionStatus = $state<ConnectionStatus>('disconnected');
40+ private _sseError = $state<string | null>(null);
41+
42+ // Internal state flags
43+ private isInitializing = $state(false);
3144 private isSettingUser = $state(false);
32- private isConnecting = $state(false);
33- private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;
45+
46+ // Memoization for enriched messages
3447 private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
3548 private _lastEnrichmentKey: string = '';
36- // Flag to avoid re-registering SSE event handlers
37- private sseHandlersRegistered = false;
38- private lastBuddyListUpdate = 0;
49+
50+ // Buddy list tracking
3951 private lastBuddyListHash = '';
40- private hasMoreMessages = $state(false);
52+
53+ // Public polling for unauthenticated users
4154 private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
4255
56+ // Derived state
57+ public connectionStatus = $derived(this._connectionStatus);
58+ public sseError = $derived(this._sseError);
59+ public isConnected = $derived(this._connectionStatus === 'connected');
60+ public isReconnecting = $derived(this._connectionStatus === 'reconnecting');
61+
62+ /**
63+ * Reinitialize the chat state (e.g., after login)
64+ */
4365 public async reinitialize() {
4466 if (this.isInitializing) {
4567 console.debug('Reinitialize already in progress, skipping.');
@@ -47,43 +69,54 @@ class ChatState {
4769 }
4870 this.isInitializing = true;
4971 console.debug('Reinitializing chat state...');
50- if (this.eventSource) {
51- this.eventSource.close();
52- this.eventSource = null;
72+
73+ // Disconnect existing SSE client
74+ if (this.sseClient) {
75+ this.sseClient.disconnect();
76+ this.sseClient = null;
5377 }
54- this.reconnectAttempts = 0;
55- this.reconnectDelay = 1000;
78+
5679 this.messages = [];
57- // Ensure currentRoomId is set
5880 this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
5981
6082 if (this.currentUser) {
6183 console.debug('Waiting for session cookie update before setting up SSE...');
6284 await new Promise((resolve) => setTimeout(resolve, 1000));
63- // First set up SSE to receive status updates
85+
86+ // Set up SSE connection
6487 this.setupSSE();
65- // Then initialize messages and room users
88+
89+ // Initialize messages and room users
6690 await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);
6791 }
92+
6893 this.isInitializing = false;
6994 }
7095
96+ /**
97+ * Get SSE error information
98+ */
7199 getSSEError() {
72100 return {
73- error: this.sseError,
74- retryAfter: this.sseRetryAfter
101+ error: this._sseError,
102+ status: this._connectionStatus,
103+ retryAfter: this.sseClient?.getReconnectDelay() ?? null
75104 };
76105 }
77106
78- // User methods
107+ /**
108+ * Get the current user
109+ */
79110 getCurrentUser() {
80111 return this.currentUser;
81112 }
82113
114+ /**
115+ * Set the current user
116+ */
83117 async setCurrentUser(user: User | SafeUser | null) {
84118 console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
85119
86- // Prevent concurrent setCurrentUser calls
87120 if (this.isSettingUser) {
88121 console.debug('Already setting user, skipping');
89122 return;
@@ -92,18 +125,16 @@ class ChatState {
92125 this.isSettingUser = true;
93126
94127 try {
95- // If user is null and we already have no user, skip
96128 if (!user && !this.currentUser) {
97129 console.debug('No user to set and no current user, skipping');
98130 return;
99131 }
100132
101- // Convert to SafeUser if full User object is passed
102133 const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
103134
104- // If same user is being set, check SSE connection
135+ // Check if same user with active connection
105136 if (safeUser?.id === this.currentUser?.id) {
106- if (!this.eventSource) {
137+ if (!this.sseClient || !this.sseClient.isConnected) {
107138 console.debug('Same user being set but no active SSE connection, reinitializing.');
108139 } else {
109140 console.debug('Same user being set with active connection, skipping.');
@@ -111,31 +142,31 @@ class ChatState {
111142 }
112143 }
113144
114- // Clear existing messages before setting new user
115145 this.messages = [];
116146 this.currentUser = safeUser;
117147
118148 if (safeUser) {
119- // Update user cache with current user
120149 this.userCache[safeUser.id] = safeUser;
121-
122- // Only reinitialize if we have a valid user
123150 await this.reinitialize();
124151 } else {
125152 // Cleanup when user is removed
126- if (this.eventSource) {
127- this.eventSource.close();
128- this.eventSource = null;
153+ if (this.sseClient) {
154+ this.sseClient.disconnect();
155+ this.sseClient = null;
129156 }
130157 this.messages = [];
131158 this.users = [];
132159 this.userCache = {};
160+ this._connectionStatus = 'disconnected';
133161 }
134162 } finally {
135163 this.isSettingUser = false;
136164 }
137165 }
138166
167+ /**
168+ * Initialize room users
169+ */
139170 private async initializeRoomUsers() {
140171 console.debug('Initializing room users for room:', this.currentRoomId);
141172 try {
@@ -143,9 +174,7 @@ class ChatState {
143174 const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
144175 console.debug('Fetching room users from:', url);
145176
146- const response = await fetch(url, {
147- credentials: 'include'
148- });
177+ const response = await fetch(url, { credentials: 'include' });
149178
150179 if (!response.ok) {
151180 throw new Error('Failed to fetch room users');
@@ -157,20 +186,14 @@ class ChatState {
157186 }
158187
159188 console.debug('Fetched room buddy list:', data.buddyList);
160- // Merge fetched buddy list with existing cached users to preserve user details even if offline
161- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging, not reactive state
162- const mergedUsers = new Map<string, (typeof data.buddyList)[0]>();
163- // Add existing cached users
164- Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
165189
166- // Overwrite/add with fetched buddy list
167- data.buddyList.forEach((user: (typeof data.buddyList)[0]) => {
168- mergedUsers.set(user.id, user);
169- });
190+ // Merge fetched buddy list with existing cached users
191+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging
192+ const mergedUsers = new Map<string, SafeUser>();
193+ Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
194+ data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user));
170195
171196 this.users = Array.from(mergedUsers.values());
172-
173- // Update cache with merged users
174197 this.users.forEach((user) => {
175198 this.userCache[user.id] = user;
176199 });
@@ -180,8 +203,10 @@ class ChatState {
180203 }
181204 }
182205
206+ /**
207+ * Get online users sorted by status
208+ */
183209 getOnlineUsers() {
184- // Sort users by status: online first, then away, then busy, then offline
185210 return [...this.users].sort((a: SafeUser, b: SafeUser) => {
186211 const statusOrder: Record<SafeUser['status'], number> = {
187212 online: 0,
@@ -193,13 +218,14 @@ class ChatState {
193218 });
194219 }
195220
221+ /**
222+ * Get user by ID
223+ */
196224 getUserById(userId: string) {
197- // First check the cache
198225 if (this.userCache[userId]) {
199226 return this.userCache[userId];
200227 }
201228
202- // Return a fallback object and cache it to avoid repeated fetches
203229 const fallback: SafeUser = {
204230 id: userId,
205231 nickname: 'Unknown User',
@@ -212,6 +238,9 @@ class ChatState {
212238 return fallback;
213239 }
214240
241+ /**
242+ * Update user status
243+ */
215244 updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
216245 console.debug('Updating user status with:', {
217246 userId,
@@ -226,16 +255,14 @@ class ChatState {
226255 existingUser &&
227256 existingUser.status === status &&
228257 lastSeen &&
229- Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000
258+ Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000
230259 ) {
231260 console.debug('Skipping redundant status update for user:', userId);
232261 return;
233262 }
234263
235- // Try to get the user from cache first
236264 let user: SafeUser | undefined = this.userCache[userId];
237265
238- // If not in cache, try to find in users array
239266 if (!user) {
240267 const foundUser = this.users.find((u: SafeUser) => u.id === userId);
241268 if (foundUser) {
@@ -251,13 +278,10 @@ class ChatState {
251278 lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
252279 };
253280
254- // Update cache first
255281 this.userCache[userId] = updatedUser;
256282
257- // Then update or add to users array
258283 const index = this.users.findIndex((u: SafeUser) => u.id === userId);
259284 if (index !== -1) {
260- // Create new array with updated user
261285 this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];
262286 } else {
263287 this.users = [...this.users, updatedUser];
@@ -266,12 +290,9 @@ class ChatState {
266290 console.debug('User status updated:', {
267291 userId,
268292 newStatus: status,
269- newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(),
270- updatedInCache: this.userCache[userId].status === status,
271- updatedInList: this.users.find((u) => u.id === userId)?.status === status
293+ newLastSeen: new Date(updatedUser.lastSeen || now).toISOString()
272294 });
273295 } else {
274- // If user not found anywhere, fetch from API
275296 console.debug('User not found in cache or list, fetching from API:', userId);
276297 fetch(`/api/users/${userId}`, { credentials: 'include' })
277298 .then((response) => response.json())
@@ -282,16 +303,12 @@ class ChatState {
282303 status,
283304 lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
284305 };
285- // Update both cache and users array
286306 this.userCache[userId] = newUser;
287307 this.users = [...this.users, newUser];
288308
289309 console.debug('User fetched and status updated:', {
290310 userId,
291- status: newUser.status,
292- lastSeen: new Date(newUser.lastSeen || now).toISOString(),
293- addedToCache: !!this.userCache[userId],
294- addedToList: this.users.some((u) => u.id === userId)
311+ status: newUser.status
295312 });
296313 }
297314 })
@@ -299,7 +316,9 @@ class ChatState {
299316 }
300317 }
301318
302- // Message methods
319+ /**
320+ * Get enriched messages with user data
321+ */
303322 getMessages() {
304323 const key =
305324 this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
@@ -315,13 +334,15 @@ class ChatState {
315334 return this._memoizedEnrichedMessages;
316335 }
317336
337+ /**
338+ * Prepend messages (for loading older messages)
339+ */
318340 public prependMessages(newMessages: Message[], hasMore?: boolean) {
319341 console.debug('Prepending messages:', { count: newMessages.length });
320- // Get unique user IDs from new messages that aren't in the cache
342+
321343 const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));
322344 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
323345
324- // Fetch missing user data in parallel
325346 if (missingUserIds.length > 0) {
326347 console.debug('Fetching missing user data for IDs:', missingUserIds);
327348 Promise.all(
@@ -334,7 +355,6 @@ class ChatState {
334355 const userData = await userResponse.json();
335356 if (userData.success && userData.user) {
336357 this.userCache[userId] = userData.user;
337- // Only add to users array if not already present
338358 if (!this.users.some((u) => u.id === userId)) {
339359 this.users = [...this.users, userData.user];
340360 }
@@ -347,31 +367,29 @@ class ChatState {
347367 );
348368 }
349369
350- // Ensure incoming messages are sorted in ascending order before prepending
351370 const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
352371 const uniqueNewMessages = sortedNewMessages.filter(
353372 (msg) => !this.messages.some((m) => m.id === msg.id)
354373 );
355374 this.messages = [...uniqueNewMessages, ...this.messages];
356375
357- // Update hasMore if provided
358376 if (hasMore !== undefined) {
359377 this.hasMoreMessages = hasMore;
360378 }
361379 }
362380
363- // Add method to update user cache for public access
381+ /**
382+ * Update user cache
383+ */
364384 updateUserCache(users: (User | SafeUser)[]) {
365385 console.debug(
366386 'Updating user cache with users:',
367387 users.map((u) => ({ ...u, password: '[REDACTED]' }))
368388 );
369- // Merge incoming users with the existing cache, ensuring we only store SafeUser objects
370389 users.forEach((user) => {
371390 const safeUser = 'password' in user ? createSafeUser(user) : user;
372391 if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
373392 this.userCache[user.id] = safeUser;
374- // Update users array if not already present or if status changed
375393 const existingIndex = this.users.findIndex((u) => u.id === user.id);
376394 if (existingIndex === -1) {
377395 this.users = [...this.users, safeUser];
@@ -386,14 +404,14 @@ class ChatState {
386404 });
387405 }
388406
389- // Add method to update online users for public access
407+ /**
408+ * Update online users (from buddy list updates)
409+ */
390410 updateOnlineUsers(users: (User | SafeUser)[]) {
391411 const now = Date.now();
392- // Convert all users to SafeUser and create a hash
393412 const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));
394413 const newHash = JSON.stringify(safeUsers);
395414
396- // Only update if the data has changed
397415 if (newHash !== this.lastBuddyListHash) {
398416 const statusCounts = safeUsers.reduce(
399417 (acc, user) => {
@@ -410,28 +428,27 @@ class ChatState {
410428 });
411429
412430 this.users = safeUsers;
413- // Also update cache
414431 safeUsers.forEach((user) => {
415432 this.userCache[user.id] = user;
416433 });
417434 this.lastBuddyListHash = newHash;
418- this.lastBuddyListUpdate = now;
419435 }
420436 }
421437
422- // Add method to update messages for public access
438+ /**
439+ * Update messages (for public/unauthenticated access)
440+ */
423441 updateMessages(messages: Message[]) {
424- // Deduplicate the messages by their id in case duplicates exist
425442 const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());
426- // Sort messages in ascending order (oldest first, newest at bottom)
427443 this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
428444 }
429445
430- // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE.
446+ /**
447+ * Initialize messages from the server
448+ */
431449 async initializeMessages() {
432450 try {
433- // Build the URL with appropriate parameters
434- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building, not reactive state
451+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building
435452 const params = new URLSearchParams();
436453 if (!this.currentUser) {
437454 params.append('public', 'true');
@@ -448,11 +465,9 @@ class ChatState {
448465 throw new Error(data.error);
449466 }
450467
451- // Get unique user IDs from messages that aren't in the cache
452468 const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));
453469 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
454470
455- // Fetch missing user data in parallel
456471 if (missingUserIds.length > 0) {
457472 console.debug('Fetching missing user data for IDs:', missingUserIds);
458473 await Promise.all(
@@ -465,7 +480,6 @@ class ChatState {
465480 const userData = await userResponse.json();
466481 if (userData.success && userData.user) {
467482 this.userCache[userId] = userData.user;
468- // Only add to users array if not already present
469483 if (!this.users.some((u) => u.id === userId)) {
470484 this.users = [...this.users, userData.user];
471485 }
@@ -478,10 +492,8 @@ class ChatState {
478492 );
479493 }
480494
481- // Sort messages in ascending order (oldest first, most recent at the bottom)
482495 this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
483496
484- // Update hasMore based on response
485497 if ('hasMore' in data) {
486498 this.hasMoreMessages = data.hasMore;
487499 }
@@ -498,6 +510,9 @@ class ChatState {
498510 }
499511 }
500512
513+ /**
514+ * Send a message
515+ */
501516 async sendMessage(
502517 content: string,
503518 type: Message['type'] = 'chat',
@@ -554,124 +569,91 @@ class ChatState {
554569 }
555570 }
556571
572+ /**
573+ * Set up SSE connection using the new SSEClient
574+ */
557575 private setupSSE() {
558- // Prevent creating multiple SSE connections if one is already active.
559- if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
576+ if (this.sseClient && this.sseClient.isConnected) {
560577 console.debug('setupSSE: Active SSE connection exists, skipping setup.');
561578 return;
562579 }
563- // Clear any existing timeouts
564- if (this.connectionTimeout) {
565- clearTimeout(this.connectionTimeout);
566- this.connectionTimeout = null;
567- }
568580
569- if (this.connectionAttemptTimeout) {
570- clearTimeout(this.connectionAttemptTimeout);
571- this.connectionAttemptTimeout = null;
581+ if (!this.currentUser) {
582+ console.debug('No current user, skipping SSE connection');
583+ return;
572584 }
573585
574- // Debounce connection attempts
575- this.connectionAttemptTimeout = setTimeout(() => {
576- if (!this.currentUser) {
577- console.debug('No current user, skipping SSE connection');
578- return;
579- }
586+ console.debug('Setting up SSE connection...');
580587
581- if (this.isConnecting) {
582- console.debug('Already connecting to SSE, skipping');
583- return;
588+ const config: SSEClientConfig = {
589+ url: '/api/sse',
590+ onMessage: this.handleSSEMessage.bind(this),
591+ onStatusChange: this.handleConnectionStatusChange.bind(this),
592+ onError: (error) => {
593+ this._sseError = error;
594+ console.debug('SSE error:', error);
584595 }
596+ };
585597
586- this.connectSSE();
587- }, 1000);
598+ this.sseClient = new SSEClient(config);
599+ this.sseClient.connect();
588600 }
589601
590- private async connectSSE() {
591- if (this.isConnecting) {
592- console.debug('Already connecting to SSE, skipping');
593- return;
594- }
595-
596- this.isConnecting = true;
597-
598- try {
599- // Ensure clean disconnect of any existing connection
600- if (this.eventSource) {
601- console.debug('Closing existing SSE connection');
602- this.eventSource.close();
603- this.eventSource = null;
602+ /**
603+ * Handle incoming SSE messages
604+ */
605+ private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) {
606+ switch (type) {
607+ case 'chatMessage':
608+ this.handleChatMessage(data as SSEEventMap['chatMessage']);
609+ break;
610+ case 'buddyListUpdate':
611+ this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']);
612+ break;
613+ case 'userStatusUpdate': {
614+ const statusData = data as SSEEventMap['userStatusUpdate'];
615+ this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen);
616+ break;
604617 }
605-
606- console.debug('Connecting to SSE...');
607- this.eventSource = new EventSource('/api/sse', { withCredentials: true });
608- // Reset the handlers flag for the new connection
609- this.sseHandlersRegistered = false;
610-
611- this.eventSource.onopen = () => {
612- console.debug('SSE connection established');
613- this.reconnectAttempts = 0;
614- this.reconnectDelay = 1000;
615- this.sseError = null;
616- this.sseRetryAfter = null;
617- this.isReconnecting = false;
618- };
619-
620- // Set up error handling
621- this.eventSource.onerror = (error) => {
622- console.debug('SSE connection error:', error);
623- if (this.eventSource?.readyState === EventSource.CLOSED) {
624- this.handleDisconnection();
625- }
626- };
627-
628- // Register event handlers (only once)
629- this.setupMessageHandlers();
630- } catch (error) {
631- console.debug('Error setting up SSE connection:', error);
632- this.handleDisconnection();
633- } finally {
634- this.isConnecting = false;
618+ case 'heartbeat':
619+ // Heartbeat is handled by SSEClient, no action needed here
620+ break;
621+ default:
622+ console.debug('Unknown SSE event type:', type);
635623 }
636624 }
637625
638- // Register SSE event handlers only once per connection.
639- private setupMessageHandlers() {
640- if (this.sseHandlersRegistered || !this.eventSource) {
641- return;
642- }
626+ /**
627+ * Handle chat message from SSE
628+ */
629+ private async handleChatMessage(messageData: Message) {
630+ console.debug('Received chat message via SSE:', messageData);
643631
644- // Listen for chat messages
645- this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {
646- try {
647- const messageData = JSON.parse(event.data) as Message;
648- console.debug('Received chat message via SSE:', messageData);
649- // Deduplicate and update messages array with the new message
650- this.messages = Array.from(
651- new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
652- );
653- // Sort messages so the newest are at the bottom
654- this.messages.sort((a, b) => a.timestamp - b.timestamp);
655- // Ensure the sender's data is available
656- await this.ensureUserData(messageData.senderId);
657- } catch (error) {
658- console.debug('Error handling SSE chat message:', error);
659- }
660- });
632+ // Deduplicate and update messages array
633+ this.messages = Array.from(
634+ new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
635+ );
636+ this.messages.sort((a, b) => a.timestamp - b.timestamp);
661637
662- // Modify buddy list update handler
663- this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {
664- try {
665- const buddyList = JSON.parse(event.data) as SafeUser[];
666- this.updateOnlineUsers(buddyList);
667- } catch (error) {
668- console.debug('Error handling buddy list update via SSE:', error);
669- }
670- });
638+ // Ensure the sender's data is available
639+ await this.ensureUserData(messageData.senderId);
640+ }
671641
672- this.sseHandlersRegistered = true;
642+ /**
643+ * Handle connection status changes
644+ */
645+ private handleConnectionStatusChange(status: ConnectionStatus) {
646+ this._connectionStatus = status;
647+ console.debug('SSE connection status changed:', status);
648+
649+ if (status === 'connected') {
650+ this._sseError = null;
651+ }
673652 }
674653
654+ /**
655+ * Ensure user data is loaded
656+ */
675657 private async ensureUserData(userId: string) {
676658 if (this.userCache[userId]) return;
677659
@@ -694,29 +676,9 @@ class ChatState {
694676 }
695677 }
696678
697- private handleDisconnection() {
698- console.debug('SSE connection closed, reconnecting...');
699- this.isReconnecting = true;
700- this.reconnectAttempts++;
701- if (this.reconnectAttempts > this.maxReconnectAttempts) {
702- console.debug('Max reconnect attempts reached, giving up');
703- if (this.eventSource) {
704- this.eventSource.close();
705- }
706- this.eventSource = null;
707- return;
708- }
709- this.reconnectDelay *= 2; // Exponential backoff
710- // Ensure any existing connection is properly closed before reconnecting
711- if (this.eventSource) {
712- this.eventSource.close();
713- this.eventSource = null;
714- }
715- // Reset the handlers flag so that the new connection gets its event listeners registered fresh.
716- this.sseHandlersRegistered = false;
717- this.setupSSE();
718- }
719-
679+ /**
680+ * Enrich messages with user data
681+ */
720682 enrichMessages(messages: Message[]): EnrichedMessage[] {
721683 console.debug('Enriching messages with user data', {
722684 messagesCount: messages.length,
@@ -734,9 +696,26 @@ class ChatState {
734696 }));
735697 }
736698
699+ /**
700+ * Get the default chat room ID
701+ */
737702 getDefaultChatRoomId() {
738703 return DEFAULT_CHAT_ROOM_ID;
739704 }
705+
706+ /**
707+ * Disconnect SSE (for cleanup)
708+ */
709+ disconnect() {
710+ if (this.sseClient) {
711+ this.sseClient.disconnect();
712+ this.sseClient = null;
713+ }
714+ if (this.publicPollingInterval) {
715+ clearInterval(this.publicPollingInterval);
716+ this.publicPollingInterval = null;
717+ }
718+ }
740719 }
741720
742721 export const chatState = new ChatState();
@@ -1,3 +1,10 @@
1+/**
2+ * Chat State Management
3+ *
4+ * Central state manager for chat functionality using Svelte 5 runes.
5+ * Handles SSE connections, messages, and user state.
6+ */
7+
1 import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';8 import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat';
2 import { createSafeUser } from '../types/chat';9 import { createSafeUser } from '../types/chat';
3 import type {10 import type {
@@ -8,38 +15,53 @@ import type {
8 import type { TextStyle } from '../types/text-formatting';15 import type { TextStyle } from '../types/text-formatting';
9 import { invalidate } from '$app/navigation';16 import { invalidate } from '$app/navigation';
10 import { env } from '$env/dynamic/public';17 import { env } from '$env/dynamic/public';
18+import { SSEClient, type SSEClientConfig } from '../sse/SSEClient.svelte';
19+import type { SSEEventMap } from '../sse/SSEEventEmitter';
20+import type { ConnectionStatus } from '../sse/config';
11 21
12 // Use the public environment variable with a fallback22 // Use the public environment variable with a fallback
13 const DEFAULT_CHAT_ROOM_ID =23 const DEFAULT_CHAT_ROOM_ID =
14 env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';24 env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
15 25
16 class ChatState {26 class ChatState {
27+ // User and message state
17 private users = $state<SafeUser[]>([]);28 private users = $state<SafeUser[]>([]);
18 private messages = $state<Message[]>([]);29 private messages = $state<Message[]>([]);
19 private currentUser = $state<SafeUser | null>(null);30 private currentUser = $state<SafeUser | null>(null);
20- private eventSource: EventSource | null = null;
21- private reconnectAttempts = 0;
22- private maxReconnectAttempts = 5;
23- private reconnectDelay = 1000; // Start with 1 second
24- private isInitializing = $state(false);
25 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);31 private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID);
26 private userCache = $state<Record<string, SafeUser>>({});32 private userCache = $state<Record<string, SafeUser>>({});
27- private sseError = $state<string | null>(null);33+ private hasMoreMessages = $state(false);
28- private sseRetryAfter = $state<number | null>(null);34+
29- private connectionTimeout: ReturnType<typeof setTimeout> | null = null;35+ // SSE Client
30- private isReconnecting = $state(false);36+ private sseClient: SSEClient | null = null;
37+
38+ // Connection state
39+ private _connectionStatus = $state<ConnectionStatus>('disconnected');
40+ private _sseError = $state<string | null>(null);
41+
42+ // Internal state flags
43+ private isInitializing = $state(false);
31 private isSettingUser = $state(false);44 private isSettingUser = $state(false);
32- private isConnecting = $state(false);45+
33- private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null;46+ // Memoization for enriched messages
34 private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;47 private _memoizedEnrichedMessages: EnrichedMessage[] | null = null;
35 private _lastEnrichmentKey: string = '';48 private _lastEnrichmentKey: string = '';
36- // Flag to avoid re-registering SSE event handlers49+
37- private sseHandlersRegistered = false;50+ // Buddy list tracking
38- private lastBuddyListUpdate = 0;
39 private lastBuddyListHash = '';51 private lastBuddyListHash = '';
40- private hasMoreMessages = $state(false);52+
53+ // Public polling for unauthenticated users
41 private publicPollingInterval: ReturnType<typeof setInterval> | null = null;54 private publicPollingInterval: ReturnType<typeof setInterval> | null = null;
42 55
56+ // Derived state
57+ public connectionStatus = $derived(this._connectionStatus);
58+ public sseError = $derived(this._sseError);
59+ public isConnected = $derived(this._connectionStatus === 'connected');
60+ public isReconnecting = $derived(this._connectionStatus === 'reconnecting');
61+
62+ /**
63+ * Reinitialize the chat state (e.g., after login)
64+ */
43 public async reinitialize() {65 public async reinitialize() {
44 if (this.isInitializing) {66 if (this.isInitializing) {
45 console.debug('Reinitialize already in progress, skipping.');67 console.debug('Reinitialize already in progress, skipping.');
@@ -47,43 +69,54 @@ class ChatState {
47 }69 }
48 this.isInitializing = true;70 this.isInitializing = true;
49 console.debug('Reinitializing chat state...');71 console.debug('Reinitializing chat state...');
50- if (this.eventSource) {72+
51- this.eventSource.close();73+ // Disconnect existing SSE client
52- this.eventSource = null;74+ if (this.sseClient) {
75+ this.sseClient.disconnect();
76+ this.sseClient = null;
53 }77 }
54- this.reconnectAttempts = 0;78+
55- this.reconnectDelay = 1000;
56 this.messages = [];79 this.messages = [];
57- // Ensure currentRoomId is set
58 this.currentRoomId = DEFAULT_CHAT_ROOM_ID;80 this.currentRoomId = DEFAULT_CHAT_ROOM_ID;
59 81
60 if (this.currentUser) {82 if (this.currentUser) {
61 console.debug('Waiting for session cookie update before setting up SSE...');83 console.debug('Waiting for session cookie update before setting up SSE...');
62 await new Promise((resolve) => setTimeout(resolve, 1000));84 await new Promise((resolve) => setTimeout(resolve, 1000));
63- // First set up SSE to receive status updates85+
86+ // Set up SSE connection
64 this.setupSSE();87 this.setupSSE();
65- // Then initialize messages and room users88+
89+ // Initialize messages and room users
66 await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);90 await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]);
67 }91 }
92+
68 this.isInitializing = false;93 this.isInitializing = false;
69 }94 }
70 95
96+ /**
97+ * Get SSE error information
98+ */
71 getSSEError() {99 getSSEError() {
72 return {100 return {
73- error: this.sseError,101+ error: this._sseError,
74- retryAfter: this.sseRetryAfter102+ status: this._connectionStatus,
103+ retryAfter: this.sseClient?.getReconnectDelay() ?? null
75 };104 };
76 }105 }
77 106
78- // User methods107+ /**
108+ * Get the current user
109+ */
79 getCurrentUser() {110 getCurrentUser() {
80 return this.currentUser;111 return this.currentUser;
81 }112 }
82 113
114+ /**
115+ * Set the current user
116+ */
83 async setCurrentUser(user: User | SafeUser | null) {117 async setCurrentUser(user: User | SafeUser | null) {
84 console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);118 console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null);
85 119
86- // Prevent concurrent setCurrentUser calls
87 if (this.isSettingUser) {120 if (this.isSettingUser) {
88 console.debug('Already setting user, skipping');121 console.debug('Already setting user, skipping');
89 return;122 return;
@@ -92,18 +125,16 @@ class ChatState {
92 this.isSettingUser = true;125 this.isSettingUser = true;
93 126
94 try {127 try {
95- // If user is null and we already have no user, skip
96 if (!user && !this.currentUser) {128 if (!user && !this.currentUser) {
97 console.debug('No user to set and no current user, skipping');129 console.debug('No user to set and no current user, skipping');
98 return;130 return;
99 }131 }
100 132
101- // Convert to SafeUser if full User object is passed
102 const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;133 const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null;
103 134
104- // If same user is being set, check SSE connection135+ // Check if same user with active connection
105 if (safeUser?.id === this.currentUser?.id) {136 if (safeUser?.id === this.currentUser?.id) {
106- if (!this.eventSource) {137+ if (!this.sseClient || !this.sseClient.isConnected) {
107 console.debug('Same user being set but no active SSE connection, reinitializing.');138 console.debug('Same user being set but no active SSE connection, reinitializing.');
108 } else {139 } else {
109 console.debug('Same user being set with active connection, skipping.');140 console.debug('Same user being set with active connection, skipping.');
@@ -111,31 +142,31 @@ class ChatState {
111 }142 }
112 }143 }
113 144
114- // Clear existing messages before setting new user
115 this.messages = [];145 this.messages = [];
116 this.currentUser = safeUser;146 this.currentUser = safeUser;
117 147
118 if (safeUser) {148 if (safeUser) {
119- // Update user cache with current user
120 this.userCache[safeUser.id] = safeUser;149 this.userCache[safeUser.id] = safeUser;
121-
122- // Only reinitialize if we have a valid user
123 await this.reinitialize();150 await this.reinitialize();
124 } else {151 } else {
125 // Cleanup when user is removed152 // Cleanup when user is removed
126- if (this.eventSource) {153+ if (this.sseClient) {
127- this.eventSource.close();154+ this.sseClient.disconnect();
128- this.eventSource = null;155+ this.sseClient = null;
129 }156 }
130 this.messages = [];157 this.messages = [];
131 this.users = [];158 this.users = [];
132 this.userCache = {};159 this.userCache = {};
160+ this._connectionStatus = 'disconnected';
133 }161 }
134 } finally {162 } finally {
135 this.isSettingUser = false;163 this.isSettingUser = false;
136 }164 }
137 }165 }
138 166
167+ /**
168+ * Initialize room users
169+ */
139 private async initializeRoomUsers() {170 private async initializeRoomUsers() {
140 console.debug('Initializing room users for room:', this.currentRoomId);171 console.debug('Initializing room users for room:', this.currentRoomId);
141 try {172 try {
@@ -143,9 +174,7 @@ class ChatState {
143 const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;174 const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`;
144 console.debug('Fetching room users from:', url);175 console.debug('Fetching room users from:', url);
145 176
146- const response = await fetch(url, {177+ const response = await fetch(url, { credentials: 'include' });
147- credentials: 'include'
148- });
149 178
150 if (!response.ok) {179 if (!response.ok) {
151 throw new Error('Failed to fetch room users');180 throw new Error('Failed to fetch room users');
@@ -157,20 +186,14 @@ class ChatState {
157 }186 }
158 187
159 console.debug('Fetched room buddy list:', data.buddyList);188 console.debug('Fetched room buddy list:', data.buddyList);
160- // Merge fetched buddy list with existing cached users to preserve user details even if offline
161- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging, not reactive state
162- const mergedUsers = new Map<string, (typeof data.buddyList)[0]>();
163- // Add existing cached users
164- Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
165 189
166- // Overwrite/add with fetched buddy list190+ // Merge fetched buddy list with existing cached users
167- data.buddyList.forEach((user: (typeof data.buddyList)[0]) => {191+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- temporary Map for merging
168- mergedUsers.set(user.id, user);192+ const mergedUsers = new Map<string, SafeUser>();
169- });193+ Object.values(this.userCache).forEach((user) => mergedUsers.set(user.id, user));
194+ data.buddyList.forEach((user: SafeUser) => mergedUsers.set(user.id, user));
170 195
171 this.users = Array.from(mergedUsers.values());196 this.users = Array.from(mergedUsers.values());
172-
173- // Update cache with merged users
174 this.users.forEach((user) => {197 this.users.forEach((user) => {
175 this.userCache[user.id] = user;198 this.userCache[user.id] = user;
176 });199 });
@@ -180,8 +203,10 @@ class ChatState {
180 }203 }
181 }204 }
182 205
206+ /**
207+ * Get online users sorted by status
208+ */
183 getOnlineUsers() {209 getOnlineUsers() {
184- // Sort users by status: online first, then away, then busy, then offline
185 return [...this.users].sort((a: SafeUser, b: SafeUser) => {210 return [...this.users].sort((a: SafeUser, b: SafeUser) => {
186 const statusOrder: Record<SafeUser['status'], number> = {211 const statusOrder: Record<SafeUser['status'], number> = {
187 online: 0,212 online: 0,
@@ -193,13 +218,14 @@ class ChatState {
193 });218 });
194 }219 }
195 220
221+ /**
222+ * Get user by ID
223+ */
196 getUserById(userId: string) {224 getUserById(userId: string) {
197- // First check the cache
198 if (this.userCache[userId]) {225 if (this.userCache[userId]) {
199 return this.userCache[userId];226 return this.userCache[userId];
200 }227 }
201 228
202- // Return a fallback object and cache it to avoid repeated fetches
203 const fallback: SafeUser = {229 const fallback: SafeUser = {
204 id: userId,230 id: userId,
205 nickname: 'Unknown User',231 nickname: 'Unknown User',
@@ -212,6 +238,9 @@ class ChatState {
212 return fallback;238 return fallback;
213 }239 }
214 240
241+ /**
242+ * Update user status
243+ */
215 updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {244 updateUserStatus(userId: string, status: User['status'], lastSeen?: number) {
216 console.debug('Updating user status with:', {245 console.debug('Updating user status with:', {
217 userId,246 userId,
@@ -226,16 +255,14 @@ class ChatState {
226 existingUser &&255 existingUser &&
227 existingUser.status === status &&256 existingUser.status === status &&
228 lastSeen &&257 lastSeen &&
229- Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000258+ Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000
230 ) {259 ) {
231 console.debug('Skipping redundant status update for user:', userId);260 console.debug('Skipping redundant status update for user:', userId);
232 return;261 return;
233 }262 }
234 263
235- // Try to get the user from cache first
236 let user: SafeUser | undefined = this.userCache[userId];264 let user: SafeUser | undefined = this.userCache[userId];
237 265
238- // If not in cache, try to find in users array
239 if (!user) {266 if (!user) {
240 const foundUser = this.users.find((u: SafeUser) => u.id === userId);267 const foundUser = this.users.find((u: SafeUser) => u.id === userId);
241 if (foundUser) {268 if (foundUser) {
@@ -251,13 +278,10 @@ class ChatState {
251 lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)278 lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen)
252 };279 };
253 280
254- // Update cache first
255 this.userCache[userId] = updatedUser;281 this.userCache[userId] = updatedUser;
256 282
257- // Then update or add to users array
258 const index = this.users.findIndex((u: SafeUser) => u.id === userId);283 const index = this.users.findIndex((u: SafeUser) => u.id === userId);
259 if (index !== -1) {284 if (index !== -1) {
260- // Create new array with updated user
261 this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];285 this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)];
262 } else {286 } else {
263 this.users = [...this.users, updatedUser];287 this.users = [...this.users, updatedUser];
@@ -266,12 +290,9 @@ class ChatState {
266 console.debug('User status updated:', {290 console.debug('User status updated:', {
267 userId,291 userId,
268 newStatus: status,292 newStatus: status,
269- newLastSeen: new Date(updatedUser.lastSeen || now).toISOString(),293+ newLastSeen: new Date(updatedUser.lastSeen || now).toISOString()
270- updatedInCache: this.userCache[userId].status === status,
271- updatedInList: this.users.find((u) => u.id === userId)?.status === status
272 });294 });
273 } else {295 } else {
274- // If user not found anywhere, fetch from API
275 console.debug('User not found in cache or list, fetching from API:', userId);296 console.debug('User not found in cache or list, fetching from API:', userId);
276 fetch(`/api/users/${userId}`, { credentials: 'include' })297 fetch(`/api/users/${userId}`, { credentials: 'include' })
277 .then((response) => response.json())298 .then((response) => response.json())
@@ -282,16 +303,12 @@ class ChatState {
282 status,303 status,
283 lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)304 lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen)
284 };305 };
285- // Update both cache and users array
286 this.userCache[userId] = newUser;306 this.userCache[userId] = newUser;
287 this.users = [...this.users, newUser];307 this.users = [...this.users, newUser];
288 308
289 console.debug('User fetched and status updated:', {309 console.debug('User fetched and status updated:', {
290 userId,310 userId,
291- status: newUser.status,311+ status: newUser.status
292- lastSeen: new Date(newUser.lastSeen || now).toISOString(),
293- addedToCache: !!this.userCache[userId],
294- addedToList: this.users.some((u) => u.id === userId)
295 });312 });
296 }313 }
297 })314 })
@@ -299,7 +316,9 @@ class ChatState {
299 }316 }
300 }317 }
301 318
302- // Message methods319+ /**
320+ * Get enriched messages with user data
321+ */
303 getMessages() {322 getMessages() {
304 const key =323 const key =
305 this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');324 this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(',');
@@ -315,13 +334,15 @@ class ChatState {
315 return this._memoizedEnrichedMessages;334 return this._memoizedEnrichedMessages;
316 }335 }
317 336
337+ /**
338+ * Prepend messages (for loading older messages)
339+ */
318 public prependMessages(newMessages: Message[], hasMore?: boolean) {340 public prependMessages(newMessages: Message[], hasMore?: boolean) {
319 console.debug('Prepending messages:', { count: newMessages.length });341 console.debug('Prepending messages:', { count: newMessages.length });
320- // Get unique user IDs from new messages that aren't in the cache342+
321 const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));343 const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId));
322 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);344 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
323 345
324- // Fetch missing user data in parallel
325 if (missingUserIds.length > 0) {346 if (missingUserIds.length > 0) {
326 console.debug('Fetching missing user data for IDs:', missingUserIds);347 console.debug('Fetching missing user data for IDs:', missingUserIds);
327 Promise.all(348 Promise.all(
@@ -334,7 +355,6 @@ class ChatState {
334 const userData = await userResponse.json();355 const userData = await userResponse.json();
335 if (userData.success && userData.user) {356 if (userData.success && userData.user) {
336 this.userCache[userId] = userData.user;357 this.userCache[userId] = userData.user;
337- // Only add to users array if not already present
338 if (!this.users.some((u) => u.id === userId)) {358 if (!this.users.some((u) => u.id === userId)) {
339 this.users = [...this.users, userData.user];359 this.users = [...this.users, userData.user];
340 }360 }
@@ -347,31 +367,29 @@ class ChatState {
347 );367 );
348 }368 }
349 369
350- // Ensure incoming messages are sorted in ascending order before prepending
351 const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);370 const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp);
352 const uniqueNewMessages = sortedNewMessages.filter(371 const uniqueNewMessages = sortedNewMessages.filter(
353 (msg) => !this.messages.some((m) => m.id === msg.id)372 (msg) => !this.messages.some((m) => m.id === msg.id)
354 );373 );
355 this.messages = [...uniqueNewMessages, ...this.messages];374 this.messages = [...uniqueNewMessages, ...this.messages];
356 375
357- // Update hasMore if provided
358 if (hasMore !== undefined) {376 if (hasMore !== undefined) {
359 this.hasMoreMessages = hasMore;377 this.hasMoreMessages = hasMore;
360 }378 }
361 }379 }
362 380
363- // Add method to update user cache for public access381+ /**
382+ * Update user cache
383+ */
364 updateUserCache(users: (User | SafeUser)[]) {384 updateUserCache(users: (User | SafeUser)[]) {
365 console.debug(385 console.debug(
366 'Updating user cache with users:',386 'Updating user cache with users:',
367 users.map((u) => ({ ...u, password: '[REDACTED]' }))387 users.map((u) => ({ ...u, password: '[REDACTED]' }))
368 );388 );
369- // Merge incoming users with the existing cache, ensuring we only store SafeUser objects
370 users.forEach((user) => {389 users.forEach((user) => {
371 const safeUser = 'password' in user ? createSafeUser(user) : user;390 const safeUser = 'password' in user ? createSafeUser(user) : user;
372 if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {391 if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) {
373 this.userCache[user.id] = safeUser;392 this.userCache[user.id] = safeUser;
374- // Update users array if not already present or if status changed
375 const existingIndex = this.users.findIndex((u) => u.id === user.id);393 const existingIndex = this.users.findIndex((u) => u.id === user.id);
376 if (existingIndex === -1) {394 if (existingIndex === -1) {
377 this.users = [...this.users, safeUser];395 this.users = [...this.users, safeUser];
@@ -386,14 +404,14 @@ class ChatState {
386 });404 });
387 }405 }
388 406
389- // Add method to update online users for public access407+ /**
408+ * Update online users (from buddy list updates)
409+ */
390 updateOnlineUsers(users: (User | SafeUser)[]) {410 updateOnlineUsers(users: (User | SafeUser)[]) {
391 const now = Date.now();411 const now = Date.now();
392- // Convert all users to SafeUser and create a hash
393 const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));412 const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user));
394 const newHash = JSON.stringify(safeUsers);413 const newHash = JSON.stringify(safeUsers);
395 414
396- // Only update if the data has changed
397 if (newHash !== this.lastBuddyListHash) {415 if (newHash !== this.lastBuddyListHash) {
398 const statusCounts = safeUsers.reduce(416 const statusCounts = safeUsers.reduce(
399 (acc, user) => {417 (acc, user) => {
@@ -410,28 +428,27 @@ class ChatState {
410 });428 });
411 429
412 this.users = safeUsers;430 this.users = safeUsers;
413- // Also update cache
414 safeUsers.forEach((user) => {431 safeUsers.forEach((user) => {
415 this.userCache[user.id] = user;432 this.userCache[user.id] = user;
416 });433 });
417 this.lastBuddyListHash = newHash;434 this.lastBuddyListHash = newHash;
418- this.lastBuddyListUpdate = now;
419 }435 }
420 }436 }
421 437
422- // Add method to update messages for public access438+ /**
439+ * Update messages (for public/unauthenticated access)
440+ */
423 updateMessages(messages: Message[]) {441 updateMessages(messages: Message[]) {
424- // Deduplicate the messages by their id in case duplicates exist
425 const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());442 const uniqueMessages = Array.from(new Map(messages.map((msg) => [msg.id, msg])).values());
426- // Sort messages in ascending order (oldest first, newest at bottom)
427 this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);443 this.messages = uniqueMessages.sort((a, b) => a.timestamp - b.timestamp);
428 }444 }
429 445
430- // Note: For authenticated users, we no longer use the message cache. Messages are fetched directly from the database, and real-time updates are handled via SSE.446+ /**
447+ * Initialize messages from the server
448+ */
431 async initializeMessages() {449 async initializeMessages() {
432 try {450 try {
433- // Build the URL with appropriate parameters451+ // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building
434- // eslint-disable-next-line svelte/prefer-svelte-reactivity -- URLSearchParams for URL building, not reactive state
435 const params = new URLSearchParams();452 const params = new URLSearchParams();
436 if (!this.currentUser) {453 if (!this.currentUser) {
437 params.append('public', 'true');454 params.append('public', 'true');
@@ -448,11 +465,9 @@ class ChatState {
448 throw new Error(data.error);465 throw new Error(data.error);
449 }466 }
450 467
451- // Get unique user IDs from messages that aren't in the cache
452 const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));468 const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId));
453 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);469 const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]);
454 470
455- // Fetch missing user data in parallel
456 if (missingUserIds.length > 0) {471 if (missingUserIds.length > 0) {
457 console.debug('Fetching missing user data for IDs:', missingUserIds);472 console.debug('Fetching missing user data for IDs:', missingUserIds);
458 await Promise.all(473 await Promise.all(
@@ -465,7 +480,6 @@ class ChatState {
465 const userData = await userResponse.json();480 const userData = await userResponse.json();
466 if (userData.success && userData.user) {481 if (userData.success && userData.user) {
467 this.userCache[userId] = userData.user;482 this.userCache[userId] = userData.user;
468- // Only add to users array if not already present
469 if (!this.users.some((u) => u.id === userId)) {483 if (!this.users.some((u) => u.id === userId)) {
470 this.users = [...this.users, userData.user];484 this.users = [...this.users, userData.user];
471 }485 }
@@ -478,10 +492,8 @@ class ChatState {
478 );492 );
479 }493 }
480 494
481- // Sort messages in ascending order (oldest first, most recent at the bottom)
482 this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);495 this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp);
483 496
484- // Update hasMore based on response
485 if ('hasMore' in data) {497 if ('hasMore' in data) {
486 this.hasMoreMessages = data.hasMore;498 this.hasMoreMessages = data.hasMore;
487 }499 }
@@ -498,6 +510,9 @@ class ChatState {
498 }510 }
499 }511 }
500 512
513+ /**
514+ * Send a message
515+ */
501 async sendMessage(516 async sendMessage(
502 content: string,517 content: string,
503 type: Message['type'] = 'chat',518 type: Message['type'] = 'chat',
@@ -554,124 +569,91 @@ class ChatState {
554 }569 }
555 }570 }
556 571
572+ /**
573+ * Set up SSE connection using the new SSEClient
574+ */
557 private setupSSE() {575 private setupSSE() {
558- // Prevent creating multiple SSE connections if one is already active.576+ if (this.sseClient && this.sseClient.isConnected) {
559- if (this.eventSource && this.eventSource.readyState !== EventSource.CLOSED) {
560 console.debug('setupSSE: Active SSE connection exists, skipping setup.');577 console.debug('setupSSE: Active SSE connection exists, skipping setup.');
561 return;578 return;
562 }579 }
563- // Clear any existing timeouts
564- if (this.connectionTimeout) {
565- clearTimeout(this.connectionTimeout);
566- this.connectionTimeout = null;
567- }
568 580
569- if (this.connectionAttemptTimeout) {581+ if (!this.currentUser) {
570- clearTimeout(this.connectionAttemptTimeout);582+ console.debug('No current user, skipping SSE connection');
571- this.connectionAttemptTimeout = null;583+ return;
572 }584 }
573 585
574- // Debounce connection attempts586+ console.debug('Setting up SSE connection...');
575- this.connectionAttemptTimeout = setTimeout(() => {
576- if (!this.currentUser) {
577- console.debug('No current user, skipping SSE connection');
578- return;
579- }
580 587
581- if (this.isConnecting) {588+ const config: SSEClientConfig = {
582- console.debug('Already connecting to SSE, skipping');589+ url: '/api/sse',
583- return;590+ onMessage: this.handleSSEMessage.bind(this),
591+ onStatusChange: this.handleConnectionStatusChange.bind(this),
592+ onError: (error) => {
593+ this._sseError = error;
594+ console.debug('SSE error:', error);
584 }595 }
596+ };
585 597
586- this.connectSSE();598+ this.sseClient = new SSEClient(config);
587- }, 1000);599+ this.sseClient.connect();
588 }600 }
589 601
590- private async connectSSE() {602+ /**
591- if (this.isConnecting) {603+ * Handle incoming SSE messages
592- console.debug('Already connecting to SSE, skipping');604+ */
593- return;605+ private handleSSEMessage<T extends keyof SSEEventMap>(type: T, data: SSEEventMap[T]) {
594- }606+ switch (type) {
595-607+ case 'chatMessage':
596- this.isConnecting = true;608+ this.handleChatMessage(data as SSEEventMap['chatMessage']);
597-609+ break;
598- try {610+ case 'buddyListUpdate':
599- // Ensure clean disconnect of any existing connection611+ this.updateOnlineUsers(data as SSEEventMap['buddyListUpdate']);
600- if (this.eventSource) {612+ break;
601- console.debug('Closing existing SSE connection');613+ case 'userStatusUpdate': {
602- this.eventSource.close();614+ const statusData = data as SSEEventMap['userStatusUpdate'];
603- this.eventSource = null;615+ this.updateUserStatus(statusData.userId, statusData.status, statusData.lastSeen);
616+ break;
604 }617 }
605-618+ case 'heartbeat':
606- console.debug('Connecting to SSE...');619+ // Heartbeat is handled by SSEClient, no action needed here
607- this.eventSource = new EventSource('/api/sse', { withCredentials: true });620+ break;
608- // Reset the handlers flag for the new connection621+ default:
609- this.sseHandlersRegistered = false;622+ console.debug('Unknown SSE event type:', type);
610-
611- this.eventSource.onopen = () => {
612- console.debug('SSE connection established');
613- this.reconnectAttempts = 0;
614- this.reconnectDelay = 1000;
615- this.sseError = null;
616- this.sseRetryAfter = null;
617- this.isReconnecting = false;
618- };
619-
620- // Set up error handling
621- this.eventSource.onerror = (error) => {
622- console.debug('SSE connection error:', error);
623- if (this.eventSource?.readyState === EventSource.CLOSED) {
624- this.handleDisconnection();
625- }
626- };
627-
628- // Register event handlers (only once)
629- this.setupMessageHandlers();
630- } catch (error) {
631- console.debug('Error setting up SSE connection:', error);
632- this.handleDisconnection();
633- } finally {
634- this.isConnecting = false;
635 }623 }
636 }624 }
637 625
638- // Register SSE event handlers only once per connection.626+ /**
639- private setupMessageHandlers() {627+ * Handle chat message from SSE
640- if (this.sseHandlersRegistered || !this.eventSource) {628+ */
641- return;629+ private async handleChatMessage(messageData: Message) {
642- }630+ console.debug('Received chat message via SSE:', messageData);
643 631
644- // Listen for chat messages632+ // Deduplicate and update messages array
645- this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => {633+ this.messages = Array.from(
646- try {634+ new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
647- const messageData = JSON.parse(event.data) as Message;635+ );
648- console.debug('Received chat message via SSE:', messageData);636+ this.messages.sort((a, b) => a.timestamp - b.timestamp);
649- // Deduplicate and update messages array with the new message
650- this.messages = Array.from(
651- new Map([...this.messages, messageData].map((m) => [m.id, m])).values()
652- );
653- // Sort messages so the newest are at the bottom
654- this.messages.sort((a, b) => a.timestamp - b.timestamp);
655- // Ensure the sender's data is available
656- await this.ensureUserData(messageData.senderId);
657- } catch (error) {
658- console.debug('Error handling SSE chat message:', error);
659- }
660- });
661 637
662- // Modify buddy list update handler638+ // Ensure the sender's data is available
663- this.eventSource.addEventListener('buddyListUpdate', async (event: MessageEvent) => {639+ await this.ensureUserData(messageData.senderId);
664- try {640+ }
665- const buddyList = JSON.parse(event.data) as SafeUser[];
666- this.updateOnlineUsers(buddyList);
667- } catch (error) {
668- console.debug('Error handling buddy list update via SSE:', error);
669- }
670- });
671 641
672- this.sseHandlersRegistered = true;642+ /**
643+ * Handle connection status changes
644+ */
645+ private handleConnectionStatusChange(status: ConnectionStatus) {
646+ this._connectionStatus = status;
647+ console.debug('SSE connection status changed:', status);
648+
649+ if (status === 'connected') {
650+ this._sseError = null;
651+ }
673 }652 }
674 653
654+ /**
655+ * Ensure user data is loaded
656+ */
675 private async ensureUserData(userId: string) {657 private async ensureUserData(userId: string) {
676 if (this.userCache[userId]) return;658 if (this.userCache[userId]) return;
677 659
@@ -694,29 +676,9 @@ class ChatState {
694 }676 }
695 }677 }
696 678
697- private handleDisconnection() {679+ /**
698- console.debug('SSE connection closed, reconnecting...');680+ * Enrich messages with user data
699- this.isReconnecting = true;681+ */
700- this.reconnectAttempts++;
701- if (this.reconnectAttempts > this.maxReconnectAttempts) {
702- console.debug('Max reconnect attempts reached, giving up');
703- if (this.eventSource) {
704- this.eventSource.close();
705- }
706- this.eventSource = null;
707- return;
708- }
709- this.reconnectDelay *= 2; // Exponential backoff
710- // Ensure any existing connection is properly closed before reconnecting
711- if (this.eventSource) {
712- this.eventSource.close();
713- this.eventSource = null;
714- }
715- // Reset the handlers flag so that the new connection gets its event listeners registered fresh.
716- this.sseHandlersRegistered = false;
717- this.setupSSE();
718- }
719-
720 enrichMessages(messages: Message[]): EnrichedMessage[] {682 enrichMessages(messages: Message[]): EnrichedMessage[] {
721 console.debug('Enriching messages with user data', {683 console.debug('Enriching messages with user data', {
722 messagesCount: messages.length,684 messagesCount: messages.length,
@@ -734,9 +696,26 @@ class ChatState {
734 }));696 }));
735 }697 }
736 698
699+ /**
700+ * Get the default chat room ID
701+ */
737 getDefaultChatRoomId() {702 getDefaultChatRoomId() {
738 return DEFAULT_CHAT_ROOM_ID;703 return DEFAULT_CHAT_ROOM_ID;
739 }704 }
705+
706+ /**
707+ * Disconnect SSE (for cleanup)
708+ */
709+ disconnect() {
710+ if (this.sseClient) {
711+ this.sseClient.disconnect();
712+ this.sseClient = null;
713+ }
714+ if (this.publicPollingInterval) {
715+ clearInterval(this.publicPollingInterval);
716+ this.publicPollingInterval = null;
717+ }
718+ }
740 }719 }
741 720
742 export const chatState = new ChatState();721 export const chatState = new ChatState();
M src/routes/+layout.svelte +5 -4
@@ -3,6 +3,7 @@
33 import { chatState } from '$lib/states/chat.svelte';
44 import { onMount } from 'svelte';
55 import { browser } from '$app/environment';
6+ import { SSE_CONFIG } from '$lib/sse/config';
67
78 let { children, data } = $props();
89 let isVisible = $state(true);
@@ -11,14 +12,14 @@
1112 let updateInProgress = $state(false);
1213 let idleTimeout = $state<number | undefined>(undefined);
1314
14- // New throttle state variables
15+ // Timing constants from SSE config for consistency
1516 let lastSentStatus = $state<string | null>(null);
1617 let lastStatusSentAt = $state<number>(0);
1718 let lastUserActivity = $state<number>(Date.now());
18- const STATUS_UPDATE_INTERVAL = 15000; // 15 seconds
19+ const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds
1920 const THROTTLE_TIME_MS = 10000; // 10 seconds
20- const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes
21- const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle
21+ const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes
22+ const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server)
2223
2324 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
2425
@@ -3,6 +3,7 @@
3 import { chatState } from '$lib/states/chat.svelte';3 import { chatState } from '$lib/states/chat.svelte';
4 import { onMount } from 'svelte';4 import { onMount } from 'svelte';
5 import { browser } from '$app/environment';5 import { browser } from '$app/environment';
6+ import { SSE_CONFIG } from '$lib/sse/config';
6 7
7 let { children, data } = $props();8 let { children, data } = $props();
8 let isVisible = $state(true);9 let isVisible = $state(true);
@@ -11,14 +12,14 @@
11 let updateInProgress = $state(false);12 let updateInProgress = $state(false);
12 let idleTimeout = $state<number | undefined>(undefined);13 let idleTimeout = $state<number | undefined>(undefined);
13 14
14- // New throttle state variables15+ // Timing constants from SSE config for consistency
15 let lastSentStatus = $state<string | null>(null);16 let lastSentStatus = $state<string | null>(null);
16 let lastStatusSentAt = $state<number>(0);17 let lastStatusSentAt = $state<number>(0);
17 let lastUserActivity = $state<number>(Date.now());18 let lastUserActivity = $state<number>(Date.now());
18- const STATUS_UPDATE_INTERVAL = 15000; // 15 seconds19+ const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds
19 const THROTTLE_TIME_MS = 10000; // 10 seconds20 const THROTTLE_TIME_MS = 10000; // 10 seconds
20- const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes21+ const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes
21- const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle22+ const IDLE_TIMEOUT = SSE_CONFIG.IDLE_TIMEOUT; // 2 minutes of inactivity = idle (aligned with server)
22 23
23 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';24 type UserStatus = 'online' | 'offline' | 'busy' | 'idle';
24 25
M src/routes/api/chat/messages/+server.ts +2 -2
@@ -10,7 +10,7 @@ import type {
1010 import { and, desc, eq, lt } from 'drizzle-orm/sql';
1111 import type { SQL } from 'drizzle-orm/sql';
1212 import { error } from '@sveltejs/kit';
13-import { sseEmitter } from '$lib/sseEmitter';
13+import { sseEmitter } from '$lib/sse/SSEEventEmitter';
1414 import { chatRooms } from '$lib/db/schema';
1515 import { users } from '$lib/db/schema';
1616 import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
@@ -220,7 +220,7 @@ export async function POST({ request, locals }: { request: Request; locals: App.
220220 });
221221
222222 // Broadcast the new message via SSE
223- sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });
223+ sseEmitter.emit('chatMessage', newMessage);
224224
225225 log.debug('Message processed successfully', {
226226 messageId: newMessage.id,
@@ -10,7 +10,7 @@ import type {
10 import { and, desc, eq, lt } from 'drizzle-orm/sql';10 import { and, desc, eq, lt } from 'drizzle-orm/sql';
11 import type { SQL } from 'drizzle-orm/sql';11 import type { SQL } from 'drizzle-orm/sql';
12 import { error } from '@sveltejs/kit';12 import { error } from '@sveltejs/kit';
13-import { sseEmitter } from '$lib/sseEmitter';13+import { sseEmitter } from '$lib/sse/SSEEventEmitter';
14 import { chatRooms } from '$lib/db/schema';14 import { chatRooms } from '$lib/db/schema';
15 import { users } from '$lib/db/schema';15 import { users } from '$lib/db/schema';
16 import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';16 import { DEFAULT_CHAT_ROOM_ID } from '$lib/utils/chat.server';
@@ -220,7 +220,7 @@ export async function POST({ request, locals }: { request: Request; locals: App.
220 });220 });
221 221
222 // Broadcast the new message via SSE222 // Broadcast the new message via SSE
223- sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage });223+ sseEmitter.emit('chatMessage', newMessage);
224 224
225 log.debug('Message processed successfully', {225 log.debug('Message processed successfully', {
226 messageId: newMessage.id,226 messageId: newMessage.id,
M src/routes/api/sse/+server.ts +207 -149
@@ -1,44 +1,56 @@
1-// At the top of the file, before any code
1+/**
2+ * SSE Server Endpoint
3+ *
4+ * Handles Server-Sent Events connections with:
5+ * - Event IDs for Last-Event-ID catch-up support
6+ * - Heartbeat mechanism for connection health
7+ * - Proper connection lifecycle management
8+ */
9+
210 declare global {
311 var __sseIntervalsStarted: boolean;
412 }
513
614 import type { RequestHandler } from './$types';
7-import { sseEmitter } from '$lib/sseEmitter';
15+import { SSE_CONFIG } from '$lib/sse/config';
16+import { sseEmitter, type SSEEvent } from '$lib/sse/SSEEventEmitter';
17+import { sseManager } from '$lib/sse/SSEManager';
18+import { messageQueue } from '$lib/sse/MessageQueue';
819 import db from '$lib/db/db.server';
920 import { users } from '$lib/db/schema';
1021 import { eq, and, lt, ne } from 'drizzle-orm';
1122 import { createSafeUser } from '$lib/types/chat';
1223 import { createLogger } from '$lib/utils/logger.server';
13-import { sseConnectionTracker } from '$lib/sseConnectionTracker';
1424 import { buddyListCache } from '$lib/buddyListCache';
25+import { env } from '$env/dynamic/public';
1526
1627 const log = createLogger('sse-server');
1728
18-console.log('[SSE] Server initialized');
19-
20-// Constants for timeout checks
21-const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
22-const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
29+console.log('[SSE] Server initialized with new SSE modules');
2330
24-// Update the interval constant
25-const BUDDY_LIST_BROADCAST_INTERVAL = 10000; // 10 seconds
31+// Default chat room ID
32+const DEFAULT_CHAT_ROOM_ID =
33+ env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
2634
35+// Intervals for background tasks
2736 let timeoutInterval: ReturnType<typeof setInterval>;
2837 let buddyListInterval: ReturnType<typeof setInterval>;
38+let connectionCleanupInterval: ReturnType<typeof setInterval>;
2939
30-// Add a timestamp tracker for buddy list updates
40+// Timestamp tracker for buddy list updates
3141 let lastBuddyListUpdate = 0;
32-let lastBuddyListData: unknown[] = [];
42+let lastBuddyListHash = '';
3343
34-// Initialize periodic check for timed out users
44+/**
45+ * Start periodic check for timed out users
46+ */
3547 function startTimeoutCheck() {
3648 if (timeoutInterval) {
3749 clearInterval(timeoutInterval);
3850 }
3951
4052 timeoutInterval = setInterval(async () => {
41- const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;
53+ const timeoutThreshold = Date.now() - SSE_CONFIG.CONNECTION_TIMEOUT;
4254 log.debug('Checking for timed out users', {
4355 checkTime: new Date().toISOString(),
4456 timeoutThreshold: new Date(timeoutThreshold).toISOString()
@@ -52,12 +64,7 @@ function startTimeoutCheck() {
5264 status: 'offline',
5365 lastSeen: now
5466 })
55- .where(
56- and(
57- ne(users.status, 'offline'), // Update any non-offline users
58- lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently
59- )
60- )
67+ .where(and(ne(users.status, 'offline'), lt(users.lastSeen, timeoutThreshold)))
6168 .returning({
6269 id: users.id,
6370 nickname: users.nickname,
@@ -65,20 +72,31 @@ function startTimeoutCheck() {
6572 });
6673
6774 if (result.length > 0) {
68- log.info('User status updated to offline', {
75+ log.info('User status updated to offline due to timeout', {
6976 usersUpdated: result.length,
7077 timeoutThreshold: new Date(timeoutThreshold).toISOString()
7178 });
7279
73- // Invalidate buddy list cache when users timeout
7480 buddyListCache.invalidate();
81+
82+ // Broadcast status updates for each timed out user
83+ for (const user of result) {
84+ sseEmitter.emit('userStatusUpdate', {
85+ userId: user.id,
86+ status: 'offline',
87+ lastSeen: now
88+ });
89+ }
7590 }
7691 } catch (error) {
7792 log.error('Error during timeout check', { error });
7893 }
79- }, CHECK_INTERVAL_MS);
94+ }, SSE_CONFIG.STALE_CHECK_INTERVAL);
8095 }
8196
97+/**
98+ * Start buddy list broadcast interval
99+ */
82100 function startBuddyListUpdateInterval() {
83101 if (buddyListInterval) {
84102 clearInterval(buddyListInterval);
@@ -87,36 +105,27 @@ function startBuddyListUpdateInterval() {
87105 buddyListInterval = setInterval(async () => {
88106 try {
89107 const now = Date.now();
90- // Only fetch and broadcast if 10 seconds have passed since last update
91- if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
92- // Only query database if cache is invalid or doesn't exist
108+ if (now - lastBuddyListUpdate >= SSE_CONFIG.BUDDY_LIST_BROADCAST_INTERVAL) {
93109 if (buddyListCache.needsRefresh()) {
94110 log.debug('Fetching buddy list from database (cache miss)...');
95- // Query the complete buddy list from the database.
96111 const buddyList = await db.select().from(users);
97- // Use createSafeUser to properly sanitize user data
98112 const safeBuddyList = buddyList.map((user) => createSafeUser(user));
99113
100- // Update cache and check if data changed
101114 const hasChanged = buddyListCache.update(safeBuddyList);
102115
103116 if (hasChanged) {
104117 log.debug('Broadcasting buddy list update (data changed)...');
105- sseEmitter.emit('sse', {
106- type: 'buddyListUpdate',
107- data: safeBuddyList
108- });
118+ sseEmitter.emit('buddyListUpdate', safeBuddyList);
109119 }
110120 } else {
111- // Use cached data
112121 const cachedData = buddyListCache.get();
113- if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) {
114- log.debug('Broadcasting buddy list update from cache...');
115- lastBuddyListData = cachedData;
116- sseEmitter.emit('sse', {
117- type: 'buddyListUpdate',
118- data: cachedData
119- });
122+ if (cachedData) {
123+ const currentHash = JSON.stringify(cachedData);
124+ if (currentHash !== lastBuddyListHash) {
125+ log.debug('Broadcasting buddy list update from cache...');
126+ lastBuddyListHash = currentHash;
127+ sseEmitter.emit('buddyListUpdate', cachedData);
128+ }
120129 }
121130 }
122131 lastBuddyListUpdate = now;
@@ -124,72 +133,78 @@ function startBuddyListUpdateInterval() {
124133 } catch (error) {
125134 log.error('Error fetching buddy list for update', { error });
126135 }
127- }, 1000); // Check every second but only broadcast every 10 seconds
136+ }, 1000);
128137 }
129138
130-// Add periodic cleanup for stale connections
131-let connectionCleanupInterval: ReturnType<typeof setInterval>;
132-
139+/**
140+ * Start periodic cleanup for stale connections
141+ */
133142 function startConnectionCleanup() {
134143 if (connectionCleanupInterval) {
135144 clearInterval(connectionCleanupInterval);
136145 }
137146
138- connectionCleanupInterval = setInterval(() => {
139- const staleUserIds = sseConnectionTracker.cleanupStaleConnections();
140- if (staleUserIds.length > 0) {
141- // Mark stale connections as offline
142- staleUserIds.forEach(async (userId) => {
143- try {
144- await db
145- .update(users)
146- .set({ status: 'offline', lastSeen: Date.now() })
147- .where(eq(users.id, userId));
148-
149- buddyListCache.invalidate();
150- } catch (error) {
151- log.error('Error updating stale user status', { userId, error });
152- }
153- });
147+ connectionCleanupInterval = setInterval(async () => {
148+ // Get stats and log them periodically
149+ const stats = sseManager.getStats();
150+ if (stats.totalConnections > 0) {
151+ log.debug('SSE connection stats', stats);
154152 }
155- }, 60000); // Run every minute
153+ }, SSE_CONFIG.STALE_CHECK_INTERVAL);
156154 }
157155
158-// <<< NEW: Ensure intervals are started only once >>>
156+// Start background tasks only once
159157 if (!globalThis.__sseIntervalsStarted) {
160158 startTimeoutCheck();
161159 startBuddyListUpdateInterval();
162160 startConnectionCleanup();
161+ sseManager.startBackgroundTasks();
163162 globalThis.__sseIntervalsStarted = true;
164- log.info('Started global intervals for timeout and buddy list updates');
163+ log.info('Started global intervals for SSE management');
165164 } else {
166165 log.debug('Global intervals already started, skipping initialization');
167166 }
168167
169-// Ensure the interval is cleared if the module is reloaded
168+// Cleanup on process exit
170169 if (typeof process !== 'undefined') {
171- process.on('beforeExit', () => {
170+ const cleanup = () => {
172171 if (timeoutInterval) clearInterval(timeoutInterval);
173172 if (buddyListInterval) clearInterval(buddyListInterval);
174173 if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
175- });
174+ sseManager.stopBackgroundTasks();
175+ };
176176
177- // Also handle SIGTERM and SIGINT
177+ process.on('beforeExit', cleanup);
178178 process.on('SIGTERM', () => {
179- if (timeoutInterval) clearInterval(timeoutInterval);
180- if (buddyListInterval) clearInterval(buddyListInterval);
181- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
179+ cleanup();
182180 process.exit(0);
183181 });
184-
185182 process.on('SIGINT', () => {
186- if (timeoutInterval) clearInterval(timeoutInterval);
187- if (buddyListInterval) clearInterval(buddyListInterval);
188- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
183+ cleanup();
189184 process.exit(0);
190185 });
191186 }
192187
188+/**
189+ * Format an SSE event with ID and retry field
190+ */
191+function formatSSEEvent(event: SSEEvent, eventId: string): string {
192+ const lines = [
193+ `id: ${eventId}`,
194+ `event: ${event.type}`,
195+ `data: ${JSON.stringify(event.data)}`,
196+ `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
197+ '',
198+ ''
199+ ];
200+ return lines.join('\n');
201+}
202+
203+/**
204+ * GET /api/sse
205+ *
206+ * Establishes an SSE connection for real-time updates.
207+ */
193208 export const GET: RequestHandler = async ({ request, locals }) => {
194209 log.debug('Processing SSE request', {
195210 url: request.url,
@@ -200,119 +215,161 @@ export const GET: RequestHandler = async ({ request, locals }) => {
200215 log.warn('Authentication failed - no user in locals');
201216 return new Response('Unauthorized', {
202217 status: 401,
203- headers: {
204- 'Content-Type': 'application/json'
205- }
218+ headers: { 'Content-Type': 'application/json' }
206219 });
207220 }
208221
209222 const userId = locals.user.id;
223+ const roomId = DEFAULT_CHAT_ROOM_ID;
210224 const encoder = new TextEncoder();
211- let keepAliveInterval: ReturnType<typeof setInterval>;
212- let onSSE: ((event: { type: string; data: unknown }) => void) | undefined;
213225
214- const now = Date.now();
226+ // Check for Last-Event-ID header for catch-up
227+ const lastEventId = request.headers.get('Last-Event-ID');
228+ if (lastEventId) {
229+ log.debug('Client reconnecting with Last-Event-ID', {
230+ userId: userId.slice(0, 8),
231+ lastEventId: lastEventId.slice(0, 8)
232+ });
233+ }
215234
216- // Only update status if this is a new connection
217- const isNewConnection = sseConnectionTracker.addConnection(userId);
235+ let connectionId: string;
236+ let keepAliveInterval: ReturnType<typeof setInterval>;
237+ let cleanupListener: (() => void) | undefined;
218238
219- if (isNewConnection) {
220- try {
221- await db
222- .update(users)
223- .set({
224- status: 'online',
225- lastSeen: now
226- })
227- .where(eq(users.id, userId));
239+ const stream = new ReadableStream({
240+ async start(controller) {
241+ // Add connection to manager
242+ const { connectionId: connId, isNewUser } = sseManager.addConnection(
243+ userId,
244+ roomId,
245+ controller
246+ );
247+ connectionId = connId;
248+
249+ // Update user status if new connection
250+ if (isNewUser) {
251+ const now = Date.now();
252+ try {
253+ await db
254+ .update(users)
255+ .set({ status: 'online', lastSeen: now })
256+ .where(eq(users.id, userId));
228257
229- log.info('User status updated to online', {
230- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,
231- timestamp: new Date(now).toISOString()
232- });
258+ log.info('User status updated to online', {
259+ userId: userId.slice(0, 8),
260+ connectionId: connectionId.slice(0, 8)
261+ });
233262
234- // Invalidate buddy list cache when user status changes
235- buddyListCache.invalidate();
263+ buddyListCache.invalidate();
236264
237- sseEmitter.emit('sse', {
238- type: 'userStatusUpdate',
239- data: {
240- userId,
241- status: 'online',
242- lastSeen: now
265+ sseEmitter.emit('userStatusUpdate', {
266+ userId,
267+ status: 'online',
268+ lastSeen: now
269+ });
270+ } catch (error) {
271+ log.error('Error updating user status', { error });
243272 }
244- });
245- } catch (error) {
246- log.error('Error updating user status', { error });
247- sseConnectionTracker.removeConnection(userId);
248- }
249- } else {
250- log.debug('User already has active SSE connection, skipping status update', {
251- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
252- });
253- }
273+ }
254274
255- const stream = new ReadableStream({
256- start(controller) {
257- const sendEvent = (event: { type: string; data: unknown }) => {
275+ // Send initial connection event
276+ const connectEvent = formatSSEEvent(
277+ { type: 'userStatusUpdate', data: { userId, status: 'connected', lastSeen: Date.now() } },
278+ crypto.randomUUID()
279+ );
280+ controller.enqueue(encoder.encode(connectEvent));
281+
282+ // Send catch-up messages if reconnecting with Last-Event-ID
283+ if (lastEventId) {
284+ const missedMessages = messageQueue.getMessagesSince(roomId, lastEventId);
285+ if (missedMessages.length > 0) {
286+ log.info('Sending catch-up messages', {
287+ userId: userId.slice(0, 8),
288+ count: missedMessages.length
289+ });
290+ for (const msg of missedMessages) {
291+ // Cast to SSEEvent - the data was already validated when queued
292+ const event = { type: msg.event, data: msg.data } as SSEEvent;
293+ const payload = formatSSEEvent(event, msg.id);
294+ controller.enqueue(encoder.encode(payload));
295+ }
296+ }
297+ }
298+
299+ // Listen for SSE events
300+ const onSSE = (event: SSEEvent) => {
258301 try {
259- const payload = `event: ${event.type}\n` + `data: ${JSON.stringify(event.data)}\n\n`;
302+ const eventId = crypto.randomUUID();
303+ const payload = formatSSEEvent(event, eventId);
260304 controller.enqueue(encoder.encode(payload));
261- if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) {
262- log.info('Event sent', { type: event.type });
305+
306+ // Queue message for catch-up (if not heartbeat)
307+ if (event.type !== 'heartbeat') {
308+ messageQueue.enqueue(roomId, event.type, event.data);
263309 }
264310 } catch (error) {
265311 log.error('Error sending event', { error });
266312 }
267313 };
268314
269- onSSE = (event: { type: string; data: unknown }) => {
270- sendEvent(event);
271- };
315+ cleanupListener = sseEmitter.addListener('sse', onSSE);
272316
273- sseEmitter.addListener('sse', onSSE);
317+ // Handle abort signal
274318 request.signal.addEventListener('abort', () => {
275- if (onSSE) {
276- sseEmitter.removeListener('sse', onSSE);
319+ if (cleanupListener) {
320+ cleanupListener();
277321 log.info('Removed listener due to abort signal');
278322 }
279323 });
280- log.info('Added new listener');
281324
282- controller.enqueue(encoder.encode('data: Connected\n\n'));
325+ log.info('SSE connection established', {
326+ connectionId: connectionId.slice(0, 8),
327+ userId: userId.slice(0, 8)
328+ });
283329
330+ // Keep-alive pings (in addition to heartbeats from manager)
284331 keepAliveInterval = setInterval(() => {
285- controller.enqueue(encoder.encode(':\n\n'));
286- }, 20000);
332+ try {
333+ controller.enqueue(encoder.encode(':\n\n'));
334+ } catch {
335+ // Connection might be closed
336+ }
337+ }, SSE_CONFIG.HEARTBEAT_INTERVAL);
287338 },
339+
288340 async cancel(reason) {
289341 if (keepAliveInterval) clearInterval(keepAliveInterval);
290- if (onSSE) sseEmitter.removeListener('sse', onSSE);
291- const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
292- log.info('Connection closed', { userId: maskedUserId, reason });
342+ if (cleanupListener) cleanupListener();
293343
294- // Remove from connection tracker
295- const wasTracked = sseConnectionTracker.removeConnection(userId);
344+ const maskedUserId = userId.slice(0, 8);
345+ log.info('Connection closed', {
346+ userId: maskedUserId,
347+ connectionId: connectionId?.slice(0, 8),
348+ reason
349+ });
296350
297- // Only update database if this was the last connection
298- if (wasTracked && !sseConnectionTracker.hasConnection(userId)) {
351+ // Remove connection from manager
352+ const { isLastConnection } = sseManager.removeConnection(connectionId);
353+
354+ // Update database if last connection for this user
355+ if (isLastConnection) {
299356 const now = Date.now();
300- await db
301- .update(users)
302- .set({ status: 'offline', lastSeen: now })
303- .where(eq(users.id, userId));
357+ try {
358+ await db
359+ .update(users)
360+ .set({ status: 'offline', lastSeen: now })
361+ .where(eq(users.id, userId));
304362
305- // Invalidate buddy list cache when user goes offline
306- buddyListCache.invalidate();
363+ buddyListCache.invalidate();
307364
308- sseEmitter.emit('sse', {
309- type: 'userStatusUpdate',
310- data: {
365+ sseEmitter.emit('userStatusUpdate', {
311366 userId,
312367 status: 'offline',
313368 lastSeen: now
314- }
315- });
369+ });
370+ } catch (error) {
371+ log.error('Error updating user status on disconnect', { error });
372+ }
316373 }
317374 }
318375 });
@@ -321,7 +378,8 @@ export const GET: RequestHandler = async ({ request, locals }) => {
321378 headers: {
322379 'Content-Type': 'text/event-stream',
323380 'Cache-Control': 'no-cache',
324- Connection: 'keep-alive'
381+ Connection: 'keep-alive',
382+ 'X-Accel-Buffering': 'no' // Disable nginx buffering
325383 }
326384 });
327385 };
@@ -1,44 +1,56 @@
1-// At the top of the file, before any code1+/**
2+ * SSE Server Endpoint
3+ *
4+ * Handles Server-Sent Events connections with:
5+ * - Event IDs for Last-Event-ID catch-up support
6+ * - Heartbeat mechanism for connection health
7+ * - Proper connection lifecycle management
8+ */
9+
2 declare global {10 declare global {
3 var __sseIntervalsStarted: boolean;11 var __sseIntervalsStarted: boolean;
4 }12 }
5 13
6 import type { RequestHandler } from './$types';14 import type { RequestHandler } from './$types';
7-import { sseEmitter } from '$lib/sseEmitter';15+import { SSE_CONFIG } from '$lib/sse/config';
16+import { sseEmitter, type SSEEvent } from '$lib/sse/SSEEventEmitter';
17+import { sseManager } from '$lib/sse/SSEManager';
18+import { messageQueue } from '$lib/sse/MessageQueue';
8 import db from '$lib/db/db.server';19 import db from '$lib/db/db.server';
9 import { users } from '$lib/db/schema';20 import { users } from '$lib/db/schema';
10 import { eq, and, lt, ne } from 'drizzle-orm';21 import { eq, and, lt, ne } from 'drizzle-orm';
11 import { createSafeUser } from '$lib/types/chat';22 import { createSafeUser } from '$lib/types/chat';
12 import { createLogger } from '$lib/utils/logger.server';23 import { createLogger } from '$lib/utils/logger.server';
13-import { sseConnectionTracker } from '$lib/sseConnectionTracker';
14 import { buddyListCache } from '$lib/buddyListCache';24 import { buddyListCache } from '$lib/buddyListCache';
25+import { env } from '$env/dynamic/public';
15 26
16 const log = createLogger('sse-server');27 const log = createLogger('sse-server');
17 28
18-console.log('[SSE] Server initialized');29+console.log('[SSE] Server initialized with new SSE modules');
19-
20-// Constants for timeout checks
21-const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes
22-const CHECK_INTERVAL_MS = 30 * 1000; // Check every 30 seconds
23 30
24-// Update the interval constant31+// Default chat room ID
25-const BUDDY_LIST_BROADCAST_INTERVAL = 10000; // 10 seconds32+const DEFAULT_CHAT_ROOM_ID =
33+ env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001';
26 34
35+// Intervals for background tasks
27 let timeoutInterval: ReturnType<typeof setInterval>;36 let timeoutInterval: ReturnType<typeof setInterval>;
28 let buddyListInterval: ReturnType<typeof setInterval>;37 let buddyListInterval: ReturnType<typeof setInterval>;
38+let connectionCleanupInterval: ReturnType<typeof setInterval>;
29 39
30-// Add a timestamp tracker for buddy list updates40+// Timestamp tracker for buddy list updates
31 let lastBuddyListUpdate = 0;41 let lastBuddyListUpdate = 0;
32-let lastBuddyListData: unknown[] = [];42+let lastBuddyListHash = '';
33 43
34-// Initialize periodic check for timed out users44+/**
45+ * Start periodic check for timed out users
46+ */
35 function startTimeoutCheck() {47 function startTimeoutCheck() {
36 if (timeoutInterval) {48 if (timeoutInterval) {
37 clearInterval(timeoutInterval);49 clearInterval(timeoutInterval);
38 }50 }
39 51
40 timeoutInterval = setInterval(async () => {52 timeoutInterval = setInterval(async () => {
41- const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS;53+ const timeoutThreshold = Date.now() - SSE_CONFIG.CONNECTION_TIMEOUT;
42 log.debug('Checking for timed out users', {54 log.debug('Checking for timed out users', {
43 checkTime: new Date().toISOString(),55 checkTime: new Date().toISOString(),
44 timeoutThreshold: new Date(timeoutThreshold).toISOString()56 timeoutThreshold: new Date(timeoutThreshold).toISOString()
@@ -52,12 +64,7 @@ function startTimeoutCheck() {
52 status: 'offline',64 status: 'offline',
53 lastSeen: now65 lastSeen: now
54 })66 })
55- .where(67+ .where(and(ne(users.status, 'offline'), lt(users.lastSeen, timeoutThreshold)))
56- and(
57- ne(users.status, 'offline'), // Update any non-offline users
58- lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently
59- )
60- )
61 .returning({68 .returning({
62 id: users.id,69 id: users.id,
63 nickname: users.nickname,70 nickname: users.nickname,
@@ -65,20 +72,31 @@ function startTimeoutCheck() {
65 });72 });
66 73
67 if (result.length > 0) {74 if (result.length > 0) {
68- log.info('User status updated to offline', {75+ log.info('User status updated to offline due to timeout', {
69 usersUpdated: result.length,76 usersUpdated: result.length,
70 timeoutThreshold: new Date(timeoutThreshold).toISOString()77 timeoutThreshold: new Date(timeoutThreshold).toISOString()
71 });78 });
72 79
73- // Invalidate buddy list cache when users timeout
74 buddyListCache.invalidate();80 buddyListCache.invalidate();
81+
82+ // Broadcast status updates for each timed out user
83+ for (const user of result) {
84+ sseEmitter.emit('userStatusUpdate', {
85+ userId: user.id,
86+ status: 'offline',
87+ lastSeen: now
88+ });
89+ }
75 }90 }
76 } catch (error) {91 } catch (error) {
77 log.error('Error during timeout check', { error });92 log.error('Error during timeout check', { error });
78 }93 }
79- }, CHECK_INTERVAL_MS);94+ }, SSE_CONFIG.STALE_CHECK_INTERVAL);
80 }95 }
81 96
97+/**
98+ * Start buddy list broadcast interval
99+ */
82 function startBuddyListUpdateInterval() {100 function startBuddyListUpdateInterval() {
83 if (buddyListInterval) {101 if (buddyListInterval) {
84 clearInterval(buddyListInterval);102 clearInterval(buddyListInterval);
@@ -87,36 +105,27 @@ function startBuddyListUpdateInterval() {
87 buddyListInterval = setInterval(async () => {105 buddyListInterval = setInterval(async () => {
88 try {106 try {
89 const now = Date.now();107 const now = Date.now();
90- // Only fetch and broadcast if 10 seconds have passed since last update108+ if (now - lastBuddyListUpdate >= SSE_CONFIG.BUDDY_LIST_BROADCAST_INTERVAL) {
91- if (now - lastBuddyListUpdate >= BUDDY_LIST_BROADCAST_INTERVAL) {
92- // Only query database if cache is invalid or doesn't exist
93 if (buddyListCache.needsRefresh()) {109 if (buddyListCache.needsRefresh()) {
94 log.debug('Fetching buddy list from database (cache miss)...');110 log.debug('Fetching buddy list from database (cache miss)...');
95- // Query the complete buddy list from the database.
96 const buddyList = await db.select().from(users);111 const buddyList = await db.select().from(users);
97- // Use createSafeUser to properly sanitize user data
98 const safeBuddyList = buddyList.map((user) => createSafeUser(user));112 const safeBuddyList = buddyList.map((user) => createSafeUser(user));
99 113
100- // Update cache and check if data changed
101 const hasChanged = buddyListCache.update(safeBuddyList);114 const hasChanged = buddyListCache.update(safeBuddyList);
102 115
103 if (hasChanged) {116 if (hasChanged) {
104 log.debug('Broadcasting buddy list update (data changed)...');117 log.debug('Broadcasting buddy list update (data changed)...');
105- sseEmitter.emit('sse', {118+ sseEmitter.emit('buddyListUpdate', safeBuddyList);
106- type: 'buddyListUpdate',
107- data: safeBuddyList
108- });
109 }119 }
110 } else {120 } else {
111- // Use cached data
112 const cachedData = buddyListCache.get();121 const cachedData = buddyListCache.get();
113- if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) {122+ if (cachedData) {
114- log.debug('Broadcasting buddy list update from cache...');123+ const currentHash = JSON.stringify(cachedData);
115- lastBuddyListData = cachedData;124+ if (currentHash !== lastBuddyListHash) {
116- sseEmitter.emit('sse', {125+ log.debug('Broadcasting buddy list update from cache...');
117- type: 'buddyListUpdate',126+ lastBuddyListHash = currentHash;
118- data: cachedData127+ sseEmitter.emit('buddyListUpdate', cachedData);
119- });128+ }
120 }129 }
121 }130 }
122 lastBuddyListUpdate = now;131 lastBuddyListUpdate = now;
@@ -124,72 +133,78 @@ function startBuddyListUpdateInterval() {
124 } catch (error) {133 } catch (error) {
125 log.error('Error fetching buddy list for update', { error });134 log.error('Error fetching buddy list for update', { error });
126 }135 }
127- }, 1000); // Check every second but only broadcast every 10 seconds136+ }, 1000);
128 }137 }
129 138
130-// Add periodic cleanup for stale connections139+/**
131-let connectionCleanupInterval: ReturnType<typeof setInterval>;140+ * Start periodic cleanup for stale connections
132-141+ */
133 function startConnectionCleanup() {142 function startConnectionCleanup() {
134 if (connectionCleanupInterval) {143 if (connectionCleanupInterval) {
135 clearInterval(connectionCleanupInterval);144 clearInterval(connectionCleanupInterval);
136 }145 }
137 146
138- connectionCleanupInterval = setInterval(() => {147+ connectionCleanupInterval = setInterval(async () => {
139- const staleUserIds = sseConnectionTracker.cleanupStaleConnections();148+ // Get stats and log them periodically
140- if (staleUserIds.length > 0) {149+ const stats = sseManager.getStats();
141- // Mark stale connections as offline150+ if (stats.totalConnections > 0) {
142- staleUserIds.forEach(async (userId) => {151+ log.debug('SSE connection stats', stats);
143- try {
144- await db
145- .update(users)
146- .set({ status: 'offline', lastSeen: Date.now() })
147- .where(eq(users.id, userId));
148-
149- buddyListCache.invalidate();
150- } catch (error) {
151- log.error('Error updating stale user status', { userId, error });
152- }
153- });
154 }152 }
155- }, 60000); // Run every minute153+ }, SSE_CONFIG.STALE_CHECK_INTERVAL);
156 }154 }
157 155
158-// <<< NEW: Ensure intervals are started only once >>>156+// Start background tasks only once
159 if (!globalThis.__sseIntervalsStarted) {157 if (!globalThis.__sseIntervalsStarted) {
160 startTimeoutCheck();158 startTimeoutCheck();
161 startBuddyListUpdateInterval();159 startBuddyListUpdateInterval();
162 startConnectionCleanup();160 startConnectionCleanup();
161+ sseManager.startBackgroundTasks();
163 globalThis.__sseIntervalsStarted = true;162 globalThis.__sseIntervalsStarted = true;
164- log.info('Started global intervals for timeout and buddy list updates');163+ log.info('Started global intervals for SSE management');
165 } else {164 } else {
166 log.debug('Global intervals already started, skipping initialization');165 log.debug('Global intervals already started, skipping initialization');
167 }166 }
168 167
169-// Ensure the interval is cleared if the module is reloaded168+// Cleanup on process exit
170 if (typeof process !== 'undefined') {169 if (typeof process !== 'undefined') {
171- process.on('beforeExit', () => {170+ const cleanup = () => {
172 if (timeoutInterval) clearInterval(timeoutInterval);171 if (timeoutInterval) clearInterval(timeoutInterval);
173 if (buddyListInterval) clearInterval(buddyListInterval);172 if (buddyListInterval) clearInterval(buddyListInterval);
174 if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);173 if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
175- });174+ sseManager.stopBackgroundTasks();
175+ };
176 176
177- // Also handle SIGTERM and SIGINT177+ process.on('beforeExit', cleanup);
178 process.on('SIGTERM', () => {178 process.on('SIGTERM', () => {
179- if (timeoutInterval) clearInterval(timeoutInterval);179+ cleanup();
180- if (buddyListInterval) clearInterval(buddyListInterval);
181- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
182 process.exit(0);180 process.exit(0);
183 });181 });
184-
185 process.on('SIGINT', () => {182 process.on('SIGINT', () => {
186- if (timeoutInterval) clearInterval(timeoutInterval);183+ cleanup();
187- if (buddyListInterval) clearInterval(buddyListInterval);
188- if (connectionCleanupInterval) clearInterval(connectionCleanupInterval);
189 process.exit(0);184 process.exit(0);
190 });185 });
191 }186 }
192 187
188+/**
189+ * Format an SSE event with ID and retry field
190+ */
191+function formatSSEEvent(event: SSEEvent, eventId: string): string {
192+ const lines = [
193+ `id: ${eventId}`,
194+ `event: ${event.type}`,
195+ `data: ${JSON.stringify(event.data)}`,
196+ `retry: ${SSE_CONFIG.RETRY_INTERVAL}`,
197+ '',
198+ ''
199+ ];
200+ return lines.join('\n');
201+}
202+
203+/**
204+ * GET /api/sse
205+ *
206+ * Establishes an SSE connection for real-time updates.
207+ */
193 export const GET: RequestHandler = async ({ request, locals }) => {208 export const GET: RequestHandler = async ({ request, locals }) => {
194 log.debug('Processing SSE request', {209 log.debug('Processing SSE request', {
195 url: request.url,210 url: request.url,
@@ -200,119 +215,161 @@ export const GET: RequestHandler = async ({ request, locals }) => {
200 log.warn('Authentication failed - no user in locals');215 log.warn('Authentication failed - no user in locals');
201 return new Response('Unauthorized', {216 return new Response('Unauthorized', {
202 status: 401,217 status: 401,
203- headers: {218+ headers: { 'Content-Type': 'application/json' }
204- 'Content-Type': 'application/json'
205- }
206 });219 });
207 }220 }
208 221
209 const userId = locals.user.id;222 const userId = locals.user.id;
223+ const roomId = DEFAULT_CHAT_ROOM_ID;
210 const encoder = new TextEncoder();224 const encoder = new TextEncoder();
211- let keepAliveInterval: ReturnType<typeof setInterval>;
212- let onSSE: ((event: { type: string; data: unknown }) => void) | undefined;
213 225
214- const now = Date.now();226+ // Check for Last-Event-ID header for catch-up
227+ const lastEventId = request.headers.get('Last-Event-ID');
228+ if (lastEventId) {
229+ log.debug('Client reconnecting with Last-Event-ID', {
230+ userId: userId.slice(0, 8),
231+ lastEventId: lastEventId.slice(0, 8)
232+ });
233+ }
215 234
216- // Only update status if this is a new connection235+ let connectionId: string;
217- const isNewConnection = sseConnectionTracker.addConnection(userId);236+ let keepAliveInterval: ReturnType<typeof setInterval>;
237+ let cleanupListener: (() => void) | undefined;
218 238
219- if (isNewConnection) {239+ const stream = new ReadableStream({
220- try {240+ async start(controller) {
221- await db241+ // Add connection to manager
222- .update(users)242+ const { connectionId: connId, isNewUser } = sseManager.addConnection(
223- .set({243+ userId,
224- status: 'online',244+ roomId,
225- lastSeen: now245+ controller
226- })246+ );
227- .where(eq(users.id, userId));247+ connectionId = connId;
248+
249+ // Update user status if new connection
250+ if (isNewUser) {
251+ const now = Date.now();
252+ try {
253+ await db
254+ .update(users)
255+ .set({ status: 'online', lastSeen: now })
256+ .where(eq(users.id, userId));
228 257
229- log.info('User status updated to online', {258+ log.info('User status updated to online', {
230- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`,259+ userId: userId.slice(0, 8),
231- timestamp: new Date(now).toISOString()260+ connectionId: connectionId.slice(0, 8)
232- });261+ });
233 262
234- // Invalidate buddy list cache when user status changes263+ buddyListCache.invalidate();
235- buddyListCache.invalidate();
236 264
237- sseEmitter.emit('sse', {265+ sseEmitter.emit('userStatusUpdate', {
238- type: 'userStatusUpdate',266+ userId,
239- data: {267+ status: 'online',
240- userId,268+ lastSeen: now
241- status: 'online',269+ });
242- lastSeen: now270+ } catch (error) {
271+ log.error('Error updating user status', { error });
243 }272 }
244- });273+ }
245- } catch (error) {
246- log.error('Error updating user status', { error });
247- sseConnectionTracker.removeConnection(userId);
248- }
249- } else {
250- log.debug('User already has active SSE connection, skipping status update', {
251- userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`
252- });
253- }
254 274
255- const stream = new ReadableStream({275+ // Send initial connection event
256- start(controller) {276+ const connectEvent = formatSSEEvent(
257- const sendEvent = (event: { type: string; data: unknown }) => {277+ { type: 'userStatusUpdate', data: { userId, status: 'connected', lastSeen: Date.now() } },
278+ crypto.randomUUID()
279+ );
280+ controller.enqueue(encoder.encode(connectEvent));
281+
282+ // Send catch-up messages if reconnecting with Last-Event-ID
283+ if (lastEventId) {
284+ const missedMessages = messageQueue.getMessagesSince(roomId, lastEventId);
285+ if (missedMessages.length > 0) {
286+ log.info('Sending catch-up messages', {
287+ userId: userId.slice(0, 8),
288+ count: missedMessages.length
289+ });
290+ for (const msg of missedMessages) {
291+ // Cast to SSEEvent - the data was already validated when queued
292+ const event = { type: msg.event, data: msg.data } as SSEEvent;
293+ const payload = formatSSEEvent(event, msg.id);
294+ controller.enqueue(encoder.encode(payload));
295+ }
296+ }
297+ }
298+
299+ // Listen for SSE events
300+ const onSSE = (event: SSEEvent) => {
258 try {301 try {
259- const payload = `event: ${event.type}\n` + `data: ${JSON.stringify(event.data)}\n\n`;302+ const eventId = crypto.randomUUID();
303+ const payload = formatSSEEvent(event, eventId);
260 controller.enqueue(encoder.encode(payload));304 controller.enqueue(encoder.encode(payload));
261- if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) {305+
262- log.info('Event sent', { type: event.type });306+ // Queue message for catch-up (if not heartbeat)
307+ if (event.type !== 'heartbeat') {
308+ messageQueue.enqueue(roomId, event.type, event.data);
263 }309 }
264 } catch (error) {310 } catch (error) {
265 log.error('Error sending event', { error });311 log.error('Error sending event', { error });
266 }312 }
267 };313 };
268 314
269- onSSE = (event: { type: string; data: unknown }) => {315+ cleanupListener = sseEmitter.addListener('sse', onSSE);
270- sendEvent(event);
271- };
272 316
273- sseEmitter.addListener('sse', onSSE);317+ // Handle abort signal
274 request.signal.addEventListener('abort', () => {318 request.signal.addEventListener('abort', () => {
275- if (onSSE) {319+ if (cleanupListener) {
276- sseEmitter.removeListener('sse', onSSE);320+ cleanupListener();
277 log.info('Removed listener due to abort signal');321 log.info('Removed listener due to abort signal');
278 }322 }
279 });323 });
280- log.info('Added new listener');
281 324
282- controller.enqueue(encoder.encode('data: Connected\n\n'));325+ log.info('SSE connection established', {
326+ connectionId: connectionId.slice(0, 8),
327+ userId: userId.slice(0, 8)
328+ });
283 329
330+ // Keep-alive pings (in addition to heartbeats from manager)
284 keepAliveInterval = setInterval(() => {331 keepAliveInterval = setInterval(() => {
285- controller.enqueue(encoder.encode(':\n\n'));332+ try {
286- }, 20000);333+ controller.enqueue(encoder.encode(':\n\n'));
334+ } catch {
335+ // Connection might be closed
336+ }
337+ }, SSE_CONFIG.HEARTBEAT_INTERVAL);
287 },338 },
339+
288 async cancel(reason) {340 async cancel(reason) {
289 if (keepAliveInterval) clearInterval(keepAliveInterval);341 if (keepAliveInterval) clearInterval(keepAliveInterval);
290- if (onSSE) sseEmitter.removeListener('sse', onSSE);342+ if (cleanupListener) cleanupListener();
291- const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`;
292- log.info('Connection closed', { userId: maskedUserId, reason });
293 343
294- // Remove from connection tracker344+ const maskedUserId = userId.slice(0, 8);
295- const wasTracked = sseConnectionTracker.removeConnection(userId);345+ log.info('Connection closed', {
346+ userId: maskedUserId,
347+ connectionId: connectionId?.slice(0, 8),
348+ reason
349+ });
296 350
297- // Only update database if this was the last connection351+ // Remove connection from manager
298- if (wasTracked && !sseConnectionTracker.hasConnection(userId)) {352+ const { isLastConnection } = sseManager.removeConnection(connectionId);
353+
354+ // Update database if last connection for this user
355+ if (isLastConnection) {
299 const now = Date.now();356 const now = Date.now();
300- await db357+ try {
301- .update(users)358+ await db
302- .set({ status: 'offline', lastSeen: now })359+ .update(users)
303- .where(eq(users.id, userId));360+ .set({ status: 'offline', lastSeen: now })
361+ .where(eq(users.id, userId));
304 362
305- // Invalidate buddy list cache when user goes offline363+ buddyListCache.invalidate();
306- buddyListCache.invalidate();
307 364
308- sseEmitter.emit('sse', {365+ sseEmitter.emit('userStatusUpdate', {
309- type: 'userStatusUpdate',
310- data: {
311 userId,366 userId,
312 status: 'offline',367 status: 'offline',
313 lastSeen: now368 lastSeen: now
314- }369+ });
315- });370+ } catch (error) {
371+ log.error('Error updating user status on disconnect', { error });
372+ }
316 }373 }
317 }374 }
318 });375 });
@@ -321,7 +378,8 @@ export const GET: RequestHandler = async ({ request, locals }) => {
321 headers: {378 headers: {
322 'Content-Type': 'text/event-stream',379 'Content-Type': 'text/event-stream',
323 'Cache-Control': 'no-cache',380 'Cache-Control': 'no-cache',
324- Connection: 'keep-alive'381+ Connection: 'keep-alive',
382+ 'X-Accel-Buffering': 'no' // Disable nginx buffering
325 }383 }
326 });384 });
327 };385 };
A src/routes/api/sse/heartbeat/+server.ts +66 -0
@@ -0,0 +1,66 @@
1+/**
2+ * Heartbeat Acknowledgment Endpoint
3+ *
4+ * Receives heartbeat acknowledgments from clients to track
5+ * active connections and detect stale ones.
6+ */
7+
8+import type { RequestHandler } from './$types';
9+import { sseManager } from '$lib/sse/SSEManager';
10+import { createLogger } from '$lib/utils/logger.server';
11+import { json } from '@sveltejs/kit';
12+
13+const log = createLogger('sse-heartbeat');
14+
15+/**
16+ * POST /api/sse/heartbeat
17+ *
18+ * Acknowledges a heartbeat from a client, updating the connection's
19+ * last heartbeat timestamp.
20+ */
21+export const POST: RequestHandler = async ({ request, locals }) => {
22+ // Require authentication
23+ if (!locals.user) {
24+ return json({ success: false, error: 'Unauthorized' }, { status: 401 });
25+ }
26+
27+ try {
28+ const body = await request.json();
29+ const { connectionId } = body;
30+
31+ if (!connectionId || typeof connectionId !== 'string') {
32+ return json({ success: false, error: 'Invalid connectionId' }, { status: 400 });
33+ }
34+
35+ // Verify the connection belongs to this user
36+ const connection = sseManager.getConnection(connectionId);
37+ if (!connection) {
38+ log.debug('Heartbeat for unknown connection', {
39+ connectionId: connectionId.slice(0, 8),
40+ userId: locals.user.id.slice(0, 8)
41+ });
42+ return json({ success: false, error: 'Connection not found' }, { status: 404 });
43+ }
44+
45+ if (connection.userId !== locals.user.id) {
46+ log.warn('Heartbeat userId mismatch', {
47+ connectionId: connectionId.slice(0, 8),
48+ expectedUserId: connection.userId.slice(0, 8),
49+ actualUserId: locals.user.id.slice(0, 8)
50+ });
51+ return json({ success: false, error: 'Unauthorized' }, { status: 403 });
52+ }
53+
54+ // Acknowledge the heartbeat
55+ const acknowledged = sseManager.acknowledgeHeartbeat(connectionId);
56+
57+ if (acknowledged) {
58+ return json({ success: true });
59+ } else {
60+ return json({ success: false, error: 'Failed to acknowledge' }, { status: 500 });
61+ }
62+ } catch (error) {
63+ log.error('Heartbeat error', { error });
64+ return json({ success: false, error: 'Internal server error' }, { status: 500 });
65+ }
66+};
@@ -0,0 +1,66 @@
1+/**
2+ * Heartbeat Acknowledgment Endpoint
3+ *
4+ * Receives heartbeat acknowledgments from clients to track
5+ * active connections and detect stale ones.
6+ */
7+
8+import type { RequestHandler } from './$types';
9+import { sseManager } from '$lib/sse/SSEManager';
10+import { createLogger } from '$lib/utils/logger.server';
11+import { json } from '@sveltejs/kit';
12+
13+const log = createLogger('sse-heartbeat');
14+
15+/**
16+ * POST /api/sse/heartbeat
17+ *
18+ * Acknowledges a heartbeat from a client, updating the connection's
19+ * last heartbeat timestamp.
20+ */
21+export const POST: RequestHandler = async ({ request, locals }) => {
22+ // Require authentication
23+ if (!locals.user) {
24+ return json({ success: false, error: 'Unauthorized' }, { status: 401 });
25+ }
26+
27+ try {
28+ const body = await request.json();
29+ const { connectionId } = body;
30+
31+ if (!connectionId || typeof connectionId !== 'string') {
32+ return json({ success: false, error: 'Invalid connectionId' }, { status: 400 });
33+ }
34+
35+ // Verify the connection belongs to this user
36+ const connection = sseManager.getConnection(connectionId);
37+ if (!connection) {
38+ log.debug('Heartbeat for unknown connection', {
39+ connectionId: connectionId.slice(0, 8),
40+ userId: locals.user.id.slice(0, 8)
41+ });
42+ return json({ success: false, error: 'Connection not found' }, { status: 404 });
43+ }
44+
45+ if (connection.userId !== locals.user.id) {
46+ log.warn('Heartbeat userId mismatch', {
47+ connectionId: connectionId.slice(0, 8),
48+ expectedUserId: connection.userId.slice(0, 8),
49+ actualUserId: locals.user.id.slice(0, 8)
50+ });
51+ return json({ success: false, error: 'Unauthorized' }, { status: 403 });
52+ }
53+
54+ // Acknowledge the heartbeat
55+ const acknowledged = sseManager.acknowledgeHeartbeat(connectionId);
56+
57+ if (acknowledged) {
58+ return json({ success: true });
59+ } else {
60+ return json({ success: false, error: 'Failed to acknowledge' }, { status: 500 });
61+ }
62+ } catch (error) {
63+ log.error('Heartbeat error', { error });
64+ return json({ success: false, error: 'Internal server error' }, { status: 500 });
65+ }
66+};

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