Merge pull request #56 from kitpdr/feat/sse-refactor
feat: refactor SSE infrastructure with modular client architecture
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 | + | ||
| 1 | 8 | import type { User, Message, EnrichedMessage, SafeUser } from '../types/chat'; | |
| 2 | 9 | import { createSafeUser } from '../types/chat'; | |
| 3 | 10 | import type { | |
| @@ -8,38 +15,53 @@ import type { | |||
| 8 | 15 | import type { TextStyle } from '../types/text-formatting'; | |
| 9 | 16 | import { invalidate } from '$app/navigation'; | |
| 10 | 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 | 22 | // Use the public environment variable with a fallback | |
| 13 | 23 | const DEFAULT_CHAT_ROOM_ID = | |
| 14 | 24 | env.PUBLIC_DEFAULT_CHAT_ROOM_ID || '00000000-0000-0000-0000-000000000001'; | |
| 15 | 25 | ||
| 16 | 26 | class ChatState { | |
| 27 | + | // User and message state | |
| 17 | 28 | private users = $state<SafeUser[]>([]); | |
| 18 | 29 | private messages = $state<Message[]>([]); | |
| 19 | 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 | 31 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 26 | 32 | 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); | |
| 31 | 44 | private isSettingUser = $state(false); | |
| 32 | - | private isConnecting = $state(false); | |
| 33 | - | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 45 | + | ||
| 46 | + | // Memoization for enriched messages | |
| 34 | 47 | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | |
| 35 | 48 | 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 | |
| 39 | 51 | private lastBuddyListHash = ''; | |
| 40 | - | private hasMoreMessages = $state(false); | |
| 52 | + | ||
| 53 | + | // Public polling for unauthenticated users | |
| 41 | 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 | 65 | public async reinitialize() { | |
| 44 | 66 | if (this.isInitializing) { | |
| 45 | 67 | console.debug('Reinitialize already in progress, skipping.'); | |
| @@ -47,43 +69,54 @@ class ChatState { | |||
| 47 | 69 | } | |
| 48 | 70 | this.isInitializing = true; | |
| 49 | 71 | 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; | |
| 53 | 77 | } | |
| 54 | - | this.reconnectAttempts = 0; | |
| 55 | - | this.reconnectDelay = 1000; | |
| 78 | + | ||
| 56 | 79 | this.messages = []; | |
| 57 | - | // Ensure currentRoomId is set | |
| 58 | 80 | this.currentRoomId = DEFAULT_CHAT_ROOM_ID; | |
| 59 | 81 | ||
| 60 | 82 | if (this.currentUser) { | |
| 61 | 83 | console.debug('Waiting for session cookie update before setting up SSE...'); | |
| 62 | 84 | await new Promise((resolve) => setTimeout(resolve, 1000)); | |
| 63 | - | // First set up SSE to receive status updates | |
| 85 | + | ||
| 86 | + | // Set up SSE connection | |
| 64 | 87 | this.setupSSE(); | |
| 65 | - | // Then initialize messages and room users | |
| 88 | + | ||
| 89 | + | // Initialize messages and room users | |
| 66 | 90 | await Promise.all([this.initializeMessages(), this.initializeRoomUsers()]); | |
| 67 | 91 | } | |
| 92 | + | ||
| 68 | 93 | this.isInitializing = false; | |
| 69 | 94 | } | |
| 70 | 95 | ||
| 96 | + | /** | |
| 97 | + | * Get SSE error information | |
| 98 | + | */ | |
| 71 | 99 | getSSEError() { | |
| 72 | 100 | return { | |
| 73 | - | error: this.sseError, | |
| 74 | - | retryAfter: this.sseRetryAfter | |
| 101 | + | error: this._sseError, | |
| 102 | + | status: this._connectionStatus, | |
| 103 | + | retryAfter: this.sseClient?.getReconnectDelay() ?? null | |
| 75 | 104 | }; | |
| 76 | 105 | } | |
| 77 | 106 | ||
| 78 | - | // User methods | |
| 107 | + | /** | |
| 108 | + | * Get the current user | |
| 109 | + | */ | |
| 79 | 110 | getCurrentUser() { | |
| 80 | 111 | return this.currentUser; | |
| 81 | 112 | } | |
| 82 | 113 | ||
| 114 | + | /** | |
| 115 | + | * Set the current user | |
| 116 | + | */ | |
| 83 | 117 | async setCurrentUser(user: User | SafeUser | null) { | |
| 84 | 118 | console.debug('setCurrentUser called with', user ? { ...user, password: '[REDACTED]' } : null); | |
| 85 | 119 | ||
| 86 | - | // Prevent concurrent setCurrentUser calls | |
| 87 | 120 | if (this.isSettingUser) { | |
| 88 | 121 | console.debug('Already setting user, skipping'); | |
| 89 | 122 | return; | |
| @@ -92,18 +125,16 @@ class ChatState { | |||
| 92 | 125 | this.isSettingUser = true; | |
| 93 | 126 | ||
| 94 | 127 | try { | |
| 95 | - | // If user is null and we already have no user, skip | |
| 96 | 128 | if (!user && !this.currentUser) { | |
| 97 | 129 | console.debug('No user to set and no current user, skipping'); | |
| 98 | 130 | return; | |
| 99 | 131 | } | |
| 100 | 132 | ||
| 101 | - | // Convert to SafeUser if full User object is passed | |
| 102 | 133 | const safeUser = user ? ('password' in user ? createSafeUser(user) : user) : null; | |
| 103 | 134 | ||
| 104 | - | // If same user is being set, check SSE connection | |
| 135 | + | // Check if same user with active connection | |
| 105 | 136 | if (safeUser?.id === this.currentUser?.id) { | |
| 106 | - | if (!this.eventSource) { | |
| 137 | + | if (!this.sseClient || !this.sseClient.isConnected) { | |
| 107 | 138 | console.debug('Same user being set but no active SSE connection, reinitializing.'); | |
| 108 | 139 | } else { | |
| 109 | 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 | 145 | this.messages = []; | |
| 116 | 146 | this.currentUser = safeUser; | |
| 117 | 147 | ||
| 118 | 148 | if (safeUser) { | |
| 119 | - | // Update user cache with current user | |
| 120 | 149 | this.userCache[safeUser.id] = safeUser; | |
| 121 | - | ||
| 122 | - | // Only reinitialize if we have a valid user | |
| 123 | 150 | await this.reinitialize(); | |
| 124 | 151 | } else { | |
| 125 | 152 | // 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; | |
| 129 | 156 | } | |
| 130 | 157 | this.messages = []; | |
| 131 | 158 | this.users = []; | |
| 132 | 159 | this.userCache = {}; | |
| 160 | + | this._connectionStatus = 'disconnected'; | |
| 133 | 161 | } | |
| 134 | 162 | } finally { | |
| 135 | 163 | this.isSettingUser = false; | |
| 136 | 164 | } | |
| 137 | 165 | } | |
| 138 | 166 | ||
| 167 | + | /** | |
| 168 | + | * Initialize room users | |
| 169 | + | */ | |
| 139 | 170 | private async initializeRoomUsers() { | |
| 140 | 171 | console.debug('Initializing room users for room:', this.currentRoomId); | |
| 141 | 172 | try { | |
| @@ -143,9 +174,7 @@ class ChatState { | |||
| 143 | 174 | const url = `/api/rooms/${this.currentRoomId}${isPublic ? '?public=true' : ''}`; | |
| 144 | 175 | console.debug('Fetching room users from:', url); | |
| 145 | 176 | ||
| 146 | - | const response = await fetch(url, { | |
| 147 | - | credentials: 'include' | |
| 148 | - | }); | |
| 177 | + | const response = await fetch(url, { credentials: 'include' }); | |
| 149 | 178 | ||
| 150 | 179 | if (!response.ok) { | |
| 151 | 180 | throw new Error('Failed to fetch room users'); | |
| @@ -157,20 +186,14 @@ class ChatState { | |||
| 157 | 186 | } | |
| 158 | 187 | ||
| 159 | 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 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)); | |
| 170 | 195 | ||
| 171 | 196 | this.users = Array.from(mergedUsers.values()); | |
| 172 | - | ||
| 173 | - | // Update cache with merged users | |
| 174 | 197 | this.users.forEach((user) => { | |
| 175 | 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 | 209 | getOnlineUsers() { | |
| 184 | - | // Sort users by status: online first, then away, then busy, then offline | |
| 185 | 210 | return [...this.users].sort((a: SafeUser, b: SafeUser) => { | |
| 186 | 211 | const statusOrder: Record<SafeUser['status'], number> = { | |
| 187 | 212 | online: 0, | |
| @@ -193,13 +218,14 @@ class ChatState { | |||
| 193 | 218 | }); | |
| 194 | 219 | } | |
| 195 | 220 | ||
| 221 | + | /** | |
| 222 | + | * Get user by ID | |
| 223 | + | */ | |
| 196 | 224 | getUserById(userId: string) { | |
| 197 | - | // First check the cache | |
| 198 | 225 | if (this.userCache[userId]) { | |
| 199 | 226 | return this.userCache[userId]; | |
| 200 | 227 | } | |
| 201 | 228 | ||
| 202 | - | // Return a fallback object and cache it to avoid repeated fetches | |
| 203 | 229 | const fallback: SafeUser = { | |
| 204 | 230 | id: userId, | |
| 205 | 231 | nickname: 'Unknown User', | |
| @@ -212,6 +238,9 @@ class ChatState { | |||
| 212 | 238 | return fallback; | |
| 213 | 239 | } | |
| 214 | 240 | ||
| 241 | + | /** | |
| 242 | + | * Update user status | |
| 243 | + | */ | |
| 215 | 244 | updateUserStatus(userId: string, status: User['status'], lastSeen?: number) { | |
| 216 | 245 | console.debug('Updating user status with:', { | |
| 217 | 246 | userId, | |
| @@ -226,16 +255,14 @@ class ChatState { | |||
| 226 | 255 | existingUser && | |
| 227 | 256 | existingUser.status === status && | |
| 228 | 257 | lastSeen && | |
| 229 | - | Math.abs(existingUser.lastSeen ?? 0 - lastSeen) < 5000 | |
| 258 | + | Math.abs((existingUser.lastSeen ?? 0) - lastSeen) < 5000 | |
| 230 | 259 | ) { | |
| 231 | 260 | console.debug('Skipping redundant status update for user:', userId); | |
| 232 | 261 | return; | |
| 233 | 262 | } | |
| 234 | 263 | ||
| 235 | - | // Try to get the user from cache first | |
| 236 | 264 | let user: SafeUser | undefined = this.userCache[userId]; | |
| 237 | 265 | ||
| 238 | - | // If not in cache, try to find in users array | |
| 239 | 266 | if (!user) { | |
| 240 | 267 | const foundUser = this.users.find((u: SafeUser) => u.id === userId); | |
| 241 | 268 | if (foundUser) { | |
| @@ -251,13 +278,10 @@ class ChatState { | |||
| 251 | 278 | lastSeen: lastSeen ?? (status === 'offline' ? now : user.lastSeen) | |
| 252 | 279 | }; | |
| 253 | 280 | ||
| 254 | - | // Update cache first | |
| 255 | 281 | this.userCache[userId] = updatedUser; | |
| 256 | 282 | ||
| 257 | - | // Then update or add to users array | |
| 258 | 283 | const index = this.users.findIndex((u: SafeUser) => u.id === userId); | |
| 259 | 284 | if (index !== -1) { | |
| 260 | - | // Create new array with updated user | |
| 261 | 285 | this.users = [...this.users.slice(0, index), updatedUser, ...this.users.slice(index + 1)]; | |
| 262 | 286 | } else { | |
| 263 | 287 | this.users = [...this.users, updatedUser]; | |
| @@ -266,12 +290,9 @@ class ChatState { | |||
| 266 | 290 | console.debug('User status updated:', { | |
| 267 | 291 | userId, | |
| 268 | 292 | 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() | |
| 272 | 294 | }); | |
| 273 | 295 | } else { | |
| 274 | - | // If user not found anywhere, fetch from API | |
| 275 | 296 | console.debug('User not found in cache or list, fetching from API:', userId); | |
| 276 | 297 | fetch(`/api/users/${userId}`, { credentials: 'include' }) | |
| 277 | 298 | .then((response) => response.json()) | |
| @@ -282,16 +303,12 @@ class ChatState { | |||
| 282 | 303 | status, | |
| 283 | 304 | lastSeen: lastSeen ?? (status === 'offline' ? now : data.user.lastSeen) | |
| 284 | 305 | }; | |
| 285 | - | // Update both cache and users array | |
| 286 | 306 | this.userCache[userId] = newUser; | |
| 287 | 307 | this.users = [...this.users, newUser]; | |
| 288 | 308 | ||
| 289 | 309 | console.debug('User fetched and status updated:', { | |
| 290 | 310 | 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 | |
| 295 | 312 | }); | |
| 296 | 313 | } | |
| 297 | 314 | }) | |
| @@ -299,7 +316,9 @@ class ChatState { | |||
| 299 | 316 | } | |
| 300 | 317 | } | |
| 301 | 318 | ||
| 302 | - | // Message methods | |
| 319 | + | /** | |
| 320 | + | * Get enriched messages with user data | |
| 321 | + | */ | |
| 303 | 322 | getMessages() { | |
| 304 | 323 | const key = | |
| 305 | 324 | this.messages.map((m) => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | |
| @@ -315,13 +334,15 @@ class ChatState { | |||
| 315 | 334 | return this._memoizedEnrichedMessages; | |
| 316 | 335 | } | |
| 317 | 336 | ||
| 337 | + | /** | |
| 338 | + | * Prepend messages (for loading older messages) | |
| 339 | + | */ | |
| 318 | 340 | public prependMessages(newMessages: Message[], hasMore?: boolean) { | |
| 319 | 341 | console.debug('Prepending messages:', { count: newMessages.length }); | |
| 320 | - | // Get unique user IDs from new messages that aren't in the cache | |
| 342 | + | ||
| 321 | 343 | const uniqueUserIds = new Set(newMessages.map((msg) => msg.senderId)); | |
| 322 | 344 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 323 | 345 | ||
| 324 | - | // Fetch missing user data in parallel | |
| 325 | 346 | if (missingUserIds.length > 0) { | |
| 326 | 347 | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 327 | 348 | Promise.all( | |
| @@ -334,7 +355,6 @@ class ChatState { | |||
| 334 | 355 | const userData = await userResponse.json(); | |
| 335 | 356 | if (userData.success && userData.user) { | |
| 336 | 357 | this.userCache[userId] = userData.user; | |
| 337 | - | // Only add to users array if not already present | |
| 338 | 358 | if (!this.users.some((u) => u.id === userId)) { | |
| 339 | 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 | 370 | const sortedNewMessages = newMessages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 352 | 371 | const uniqueNewMessages = sortedNewMessages.filter( | |
| 353 | 372 | (msg) => !this.messages.some((m) => m.id === msg.id) | |
| 354 | 373 | ); | |
| 355 | 374 | this.messages = [...uniqueNewMessages, ...this.messages]; | |
| 356 | 375 | ||
| 357 | - | // Update hasMore if provided | |
| 358 | 376 | if (hasMore !== undefined) { | |
| 359 | 377 | this.hasMoreMessages = hasMore; | |
| 360 | 378 | } | |
| 361 | 379 | } | |
| 362 | 380 | ||
| 363 | - | // Add method to update user cache for public access | |
| 381 | + | /** | |
| 382 | + | * Update user cache | |
| 383 | + | */ | |
| 364 | 384 | updateUserCache(users: (User | SafeUser)[]) { | |
| 365 | 385 | console.debug( | |
| 366 | 386 | 'Updating user cache with users:', | |
| 367 | 387 | users.map((u) => ({ ...u, password: '[REDACTED]' })) | |
| 368 | 388 | ); | |
| 369 | - | // Merge incoming users with the existing cache, ensuring we only store SafeUser objects | |
| 370 | 389 | users.forEach((user) => { | |
| 371 | 390 | const safeUser = 'password' in user ? createSafeUser(user) : user; | |
| 372 | 391 | if (!this.userCache[user.id] || safeUser.status !== this.userCache[user.id].status) { | |
| 373 | 392 | this.userCache[user.id] = safeUser; | |
| 374 | - | // Update users array if not already present or if status changed | |
| 375 | 393 | const existingIndex = this.users.findIndex((u) => u.id === user.id); | |
| 376 | 394 | if (existingIndex === -1) { | |
| 377 | 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 access | |
| 407 | + | /** | |
| 408 | + | * Update online users (from buddy list updates) | |
| 409 | + | */ | |
| 390 | 410 | updateOnlineUsers(users: (User | SafeUser)[]) { | |
| 391 | 411 | const now = Date.now(); | |
| 392 | - | // Convert all users to SafeUser and create a hash | |
| 393 | 412 | const safeUsers = users.map((user) => ('password' in user ? createSafeUser(user) : user)); | |
| 394 | 413 | const newHash = JSON.stringify(safeUsers); | |
| 395 | 414 | ||
| 396 | - | // Only update if the data has changed | |
| 397 | 415 | if (newHash !== this.lastBuddyListHash) { | |
| 398 | 416 | const statusCounts = safeUsers.reduce( | |
| 399 | 417 | (acc, user) => { | |
| @@ -410,28 +428,27 @@ class ChatState { | |||
| 410 | 428 | }); | |
| 411 | 429 | ||
| 412 | 430 | this.users = safeUsers; | |
| 413 | - | // Also update cache | |
| 414 | 431 | safeUsers.forEach((user) => { | |
| 415 | 432 | this.userCache[user.id] = user; | |
| 416 | 433 | }); | |
| 417 | 434 | this.lastBuddyListHash = newHash; | |
| 418 | - | this.lastBuddyListUpdate = now; | |
| 419 | 435 | } | |
| 420 | 436 | } | |
| 421 | 437 | ||
| 422 | - | // Add method to update messages for public access | |
| 438 | + | /** | |
| 439 | + | * Update messages (for public/unauthenticated access) | |
| 440 | + | */ | |
| 423 | 441 | updateMessages(messages: Message[]) { | |
| 424 | - | // Deduplicate the messages by their id in case duplicates exist | |
| 425 | 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 | 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 | 449 | async initializeMessages() { | |
| 432 | 450 | 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 | |
| 435 | 452 | const params = new URLSearchParams(); | |
| 436 | 453 | if (!this.currentUser) { | |
| 437 | 454 | params.append('public', 'true'); | |
| @@ -448,11 +465,9 @@ class ChatState { | |||
| 448 | 465 | throw new Error(data.error); | |
| 449 | 466 | } | |
| 450 | 467 | ||
| 451 | - | // Get unique user IDs from messages that aren't in the cache | |
| 452 | 468 | const uniqueUserIds = new Set(data.messages.map((msg) => msg.senderId)); | |
| 453 | 469 | const missingUserIds = Array.from(uniqueUserIds).filter((id) => !this.userCache[id]); | |
| 454 | 470 | ||
| 455 | - | // Fetch missing user data in parallel | |
| 456 | 471 | if (missingUserIds.length > 0) { | |
| 457 | 472 | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 458 | 473 | await Promise.all( | |
| @@ -465,7 +480,6 @@ class ChatState { | |||
| 465 | 480 | const userData = await userResponse.json(); | |
| 466 | 481 | if (userData.success && userData.user) { | |
| 467 | 482 | this.userCache[userId] = userData.user; | |
| 468 | - | // Only add to users array if not already present | |
| 469 | 483 | if (!this.users.some((u) => u.id === userId)) { | |
| 470 | 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 | 495 | this.messages = data.messages.slice().sort((a, b) => a.timestamp - b.timestamp); | |
| 483 | 496 | ||
| 484 | - | // Update hasMore based on response | |
| 485 | 497 | if ('hasMore' in data) { | |
| 486 | 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 | 516 | async sendMessage( | |
| 502 | 517 | content: string, | |
| 503 | 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 | 575 | 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) { | |
| 560 | 577 | console.debug('setupSSE: Active SSE connection exists, skipping setup.'); | |
| 561 | 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) { | |
| 570 | - | clearTimeout(this.connectionAttemptTimeout); | |
| 571 | - | this.connectionAttemptTimeout = null; | |
| 581 | + | if (!this.currentUser) { | |
| 582 | + | console.debug('No current user, skipping SSE connection'); | |
| 583 | + | return; | |
| 572 | 584 | } | |
| 573 | 585 | ||
| 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...'); | |
| 580 | 587 | ||
| 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); | |
| 584 | 595 | } | |
| 596 | + | }; | |
| 585 | 597 | ||
| 586 | - | this.connectSSE(); | |
| 587 | - | }, 1000); | |
| 598 | + | this.sseClient = new SSEClient(config); | |
| 599 | + | this.sseClient.connect(); | |
| 588 | 600 | } | |
| 589 | 601 | ||
| 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; | |
| 604 | 617 | } | |
| 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); | |
| 635 | 623 | } | |
| 636 | 624 | } | |
| 637 | 625 | ||
| 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); | |
| 643 | 631 | ||
| 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); | |
| 661 | 637 | ||
| 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 | + | } | |
| 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 | 657 | private async ensureUserData(userId: string) { | |
| 676 | 658 | if (this.userCache[userId]) return; | |
| 677 | 659 | ||
| @@ -694,29 +676,9 @@ class ChatState { | |||
| 694 | 676 | } | |
| 695 | 677 | } | |
| 696 | 678 | ||
| 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 | + | */ | |
| 720 | 682 | enrichMessages(messages: Message[]): EnrichedMessage[] { | |
| 721 | 683 | console.debug('Enriching messages with user data', { | |
| 722 | 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 | 702 | getDefaultChatRoomId() { | |
| 738 | 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 | 721 | 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 fallback | 22 | // 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 handlers | 49 | + | |
| 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 updates | 85 | + | |
| 86 | + | // Set up SSE connection | |||
| 64 | this.setupSSE(); | 87 | this.setupSSE(); | ||
| 65 | - | // Then initialize messages and room users | 88 | + | |
| 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.sseRetryAfter | 102 | + | status: this._connectionStatus, |
| 103 | + | retryAfter: this.sseClient?.getReconnectDelay() ?? null | |||
| 75 | }; | 104 | }; | ||
| 76 | } | 105 | } | ||
| 77 | 106 | ||||
| 78 | - | // User methods | 107 | + | /** |
| 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 connection | 135 | + | // 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 removed | 152 | // 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 list | 190 | + | // 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) < 5000 | 258 | + | 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 methods | 319 | + | /** |
| 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 cache | 342 | + | |
| 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 access | 381 | + | /** |
| 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 access | 407 | + | /** |
| 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 access | 438 | + | /** |
| 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 parameters | 451 | + | // 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 attempts | 586 | + | 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 connection | 611 | + | 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 connection | 621 | + | 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 messages | 632 | + | // 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 handler | 638 | + | // 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 @@ | |||
| 3 | 3 | import { chatState } from '$lib/states/chat.svelte'; | |
| 4 | 4 | import { onMount } from 'svelte'; | |
| 5 | 5 | import { browser } from '$app/environment'; | |
| 6 | + | import { SSE_CONFIG } from '$lib/sse/config'; | |
| 6 | 7 | ||
| 7 | 8 | let { children, data } = $props(); | |
| 8 | 9 | let isVisible = $state(true); | |
| @@ -11,14 +12,14 @@ | |||
| 11 | 12 | let updateInProgress = $state(false); | |
| 12 | 13 | let idleTimeout = $state<number | undefined>(undefined); | |
| 13 | 14 | ||
| 14 | - | // New throttle state variables | |
| 15 | + | // Timing constants from SSE config for consistency | |
| 15 | 16 | let lastSentStatus = $state<string | null>(null); | |
| 16 | 17 | let lastStatusSentAt = $state<number>(0); | |
| 17 | 18 | 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 | |
| 19 | 20 | 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) | |
| 22 | 23 | ||
| 23 | 24 | type UserStatus = 'online' | 'offline' | 'busy' | 'idle'; | |
| 24 | 25 | ||
| @@ -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 variables | 15 | + | // 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 seconds | 19 | + | const STATUS_UPDATE_INTERVAL = SSE_CONFIG.STATUS_UPDATE_INTERVAL; // 30 seconds |
| 19 | const THROTTLE_TIME_MS = 10000; // 10 seconds | 20 | const THROTTLE_TIME_MS = 10000; // 10 seconds | ||
| 20 | - | const FORCE_UPDATE_INTERVAL = 5 * 60000; // 5 minutes | 21 | + | const FORCE_UPDATE_INTERVAL = SSE_CONFIG.CONNECTION_TIMEOUT; // 5 minutes |
| 21 | - | const IDLE_TIMEOUT = 5 * 60000; // 5 minutes of inactivity = idle | 22 | + | 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 { | |||
| 10 | 10 | import { and, desc, eq, lt } from 'drizzle-orm/sql'; | |
| 11 | 11 | import type { SQL } from 'drizzle-orm/sql'; | |
| 12 | 12 | import { error } from '@sveltejs/kit'; | |
| 13 | - | import { sseEmitter } from '$lib/sseEmitter'; | |
| 13 | + | import { sseEmitter } from '$lib/sse/SSEEventEmitter'; | |
| 14 | 14 | import { chatRooms } from '$lib/db/schema'; | |
| 15 | 15 | import { users } from '$lib/db/schema'; | |
| 16 | 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 | 222 | // Broadcast the new message via SSE | |
| 223 | - | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | |
| 223 | + | sseEmitter.emit('chatMessage', newMessage); | |
| 224 | 224 | ||
| 225 | 225 | log.debug('Message processed successfully', { | |
| 226 | 226 | 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 SSE | 222 | // 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 | + | ||
| 2 | 10 | declare global { | |
| 3 | 11 | var __sseIntervalsStarted: boolean; | |
| 4 | 12 | } | |
| 5 | 13 | ||
| 6 | 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 | 19 | import db from '$lib/db/db.server'; | |
| 9 | 20 | import { users } from '$lib/db/schema'; | |
| 10 | 21 | import { eq, and, lt, ne } from 'drizzle-orm'; | |
| 11 | 22 | import { createSafeUser } from '$lib/types/chat'; | |
| 12 | 23 | import { createLogger } from '$lib/utils/logger.server'; | |
| 13 | - | import { sseConnectionTracker } from '$lib/sseConnectionTracker'; | |
| 14 | 24 | import { buddyListCache } from '$lib/buddyListCache'; | |
| 25 | + | import { env } from '$env/dynamic/public'; | |
| 15 | 26 | ||
| 16 | 27 | const log = createLogger('sse-server'); | |
| 17 | 28 | ||
| 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'); | |
| 23 | 30 | ||
| 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'; | |
| 26 | 34 | ||
| 35 | + | // Intervals for background tasks | |
| 27 | 36 | let timeoutInterval: ReturnType<typeof setInterval>; | |
| 28 | 37 | let buddyListInterval: ReturnType<typeof setInterval>; | |
| 38 | + | let connectionCleanupInterval: ReturnType<typeof setInterval>; | |
| 29 | 39 | ||
| 30 | - | // Add a timestamp tracker for buddy list updates | |
| 40 | + | // Timestamp tracker for buddy list updates | |
| 31 | 41 | let lastBuddyListUpdate = 0; | |
| 32 | - | let lastBuddyListData: unknown[] = []; | |
| 42 | + | let lastBuddyListHash = ''; | |
| 33 | 43 | ||
| 34 | - | // Initialize periodic check for timed out users | |
| 44 | + | /** | |
| 45 | + | * Start periodic check for timed out users | |
| 46 | + | */ | |
| 35 | 47 | function startTimeoutCheck() { | |
| 36 | 48 | if (timeoutInterval) { | |
| 37 | 49 | clearInterval(timeoutInterval); | |
| 38 | 50 | } | |
| 39 | 51 | ||
| 40 | 52 | timeoutInterval = setInterval(async () => { | |
| 41 | - | const timeoutThreshold = Date.now() - ONLINE_TIMEOUT_MS; | |
| 53 | + | const timeoutThreshold = Date.now() - SSE_CONFIG.CONNECTION_TIMEOUT; | |
| 42 | 54 | log.debug('Checking for timed out users', { | |
| 43 | 55 | checkTime: new Date().toISOString(), | |
| 44 | 56 | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| @@ -52,12 +64,7 @@ function startTimeoutCheck() { | |||
| 52 | 64 | status: 'offline', | |
| 53 | 65 | lastSeen: now | |
| 54 | 66 | }) | |
| 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))) | |
| 61 | 68 | .returning({ | |
| 62 | 69 | id: users.id, | |
| 63 | 70 | nickname: users.nickname, | |
| @@ -65,20 +72,31 @@ function startTimeoutCheck() { | |||
| 65 | 72 | }); | |
| 66 | 73 | ||
| 67 | 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 | 76 | usersUpdated: result.length, | |
| 70 | 77 | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 71 | 78 | }); | |
| 72 | 79 | ||
| 73 | - | // Invalidate buddy list cache when users timeout | |
| 74 | 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 | 91 | } catch (error) { | |
| 77 | 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 | 100 | function startBuddyListUpdateInterval() { | |
| 83 | 101 | if (buddyListInterval) { | |
| 84 | 102 | clearInterval(buddyListInterval); | |
| @@ -87,36 +105,27 @@ function startBuddyListUpdateInterval() { | |||
| 87 | 105 | buddyListInterval = setInterval(async () => { | |
| 88 | 106 | try { | |
| 89 | 107 | 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) { | |
| 93 | 109 | if (buddyListCache.needsRefresh()) { | |
| 94 | 110 | log.debug('Fetching buddy list from database (cache miss)...'); | |
| 95 | - | // Query the complete buddy list from the database. | |
| 96 | 111 | const buddyList = await db.select().from(users); | |
| 97 | - | // Use createSafeUser to properly sanitize user data | |
| 98 | 112 | const safeBuddyList = buddyList.map((user) => createSafeUser(user)); | |
| 99 | 113 | ||
| 100 | - | // Update cache and check if data changed | |
| 101 | 114 | const hasChanged = buddyListCache.update(safeBuddyList); | |
| 102 | 115 | ||
| 103 | 116 | if (hasChanged) { | |
| 104 | 117 | log.debug('Broadcasting buddy list update (data changed)...'); | |
| 105 | - | sseEmitter.emit('sse', { | |
| 106 | - | type: 'buddyListUpdate', | |
| 107 | - | data: safeBuddyList | |
| 108 | - | }); | |
| 118 | + | sseEmitter.emit('buddyListUpdate', safeBuddyList); | |
| 109 | 119 | } | |
| 110 | 120 | } else { | |
| 111 | - | // Use cached data | |
| 112 | 121 | 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 | + | } | |
| 120 | 129 | } | |
| 121 | 130 | } | |
| 122 | 131 | lastBuddyListUpdate = now; | |
| @@ -124,72 +133,78 @@ function startBuddyListUpdateInterval() { | |||
| 124 | 133 | } catch (error) { | |
| 125 | 134 | log.error('Error fetching buddy list for update', { error }); | |
| 126 | 135 | } | |
| 127 | - | }, 1000); // Check every second but only broadcast every 10 seconds | |
| 136 | + | }, 1000); | |
| 128 | 137 | } | |
| 129 | 138 | ||
| 130 | - | // Add periodic cleanup for stale connections | |
| 131 | - | let connectionCleanupInterval: ReturnType<typeof setInterval>; | |
| 132 | - | ||
| 139 | + | /** | |
| 140 | + | * Start periodic cleanup for stale connections | |
| 141 | + | */ | |
| 133 | 142 | function startConnectionCleanup() { | |
| 134 | 143 | if (connectionCleanupInterval) { | |
| 135 | 144 | clearInterval(connectionCleanupInterval); | |
| 136 | 145 | } | |
| 137 | 146 | ||
| 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); | |
| 154 | 152 | } | |
| 155 | - | }, 60000); // Run every minute | |
| 153 | + | }, SSE_CONFIG.STALE_CHECK_INTERVAL); | |
| 156 | 154 | } | |
| 157 | 155 | ||
| 158 | - | // <<< NEW: Ensure intervals are started only once >>> | |
| 156 | + | // Start background tasks only once | |
| 159 | 157 | if (!globalThis.__sseIntervalsStarted) { | |
| 160 | 158 | startTimeoutCheck(); | |
| 161 | 159 | startBuddyListUpdateInterval(); | |
| 162 | 160 | startConnectionCleanup(); | |
| 161 | + | sseManager.startBackgroundTasks(); | |
| 163 | 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 | 164 | } else { | |
| 166 | 165 | log.debug('Global intervals already started, skipping initialization'); | |
| 167 | 166 | } | |
| 168 | 167 | ||
| 169 | - | // Ensure the interval is cleared if the module is reloaded | |
| 168 | + | // Cleanup on process exit | |
| 170 | 169 | if (typeof process !== 'undefined') { | |
| 171 | - | process.on('beforeExit', () => { | |
| 170 | + | const cleanup = () => { | |
| 172 | 171 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 173 | 172 | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 174 | 173 | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 175 | - | }); | |
| 174 | + | sseManager.stopBackgroundTasks(); | |
| 175 | + | }; | |
| 176 | 176 | ||
| 177 | - | // Also handle SIGTERM and SIGINT | |
| 177 | + | process.on('beforeExit', cleanup); | |
| 178 | 178 | process.on('SIGTERM', () => { | |
| 179 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 180 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 181 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 179 | + | cleanup(); | |
| 182 | 180 | process.exit(0); | |
| 183 | 181 | }); | |
| 184 | - | ||
| 185 | 182 | process.on('SIGINT', () => { | |
| 186 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 187 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 188 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 183 | + | cleanup(); | |
| 189 | 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 | 208 | export const GET: RequestHandler = async ({ request, locals }) => { | |
| 194 | 209 | log.debug('Processing SSE request', { | |
| 195 | 210 | url: request.url, | |
| @@ -200,119 +215,161 @@ export const GET: RequestHandler = async ({ request, locals }) => { | |||
| 200 | 215 | log.warn('Authentication failed - no user in locals'); | |
| 201 | 216 | return new Response('Unauthorized', { | |
| 202 | 217 | status: 401, | |
| 203 | - | headers: { | |
| 204 | - | 'Content-Type': 'application/json' | |
| 205 | - | } | |
| 218 | + | headers: { 'Content-Type': 'application/json' } | |
| 206 | 219 | }); | |
| 207 | 220 | } | |
| 208 | 221 | ||
| 209 | 222 | const userId = locals.user.id; | |
| 223 | + | const roomId = DEFAULT_CHAT_ROOM_ID; | |
| 210 | 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 connection | |
| 217 | - | const isNewConnection = sseConnectionTracker.addConnection(userId); | |
| 235 | + | let connectionId: string; | |
| 236 | + | let keepAliveInterval: ReturnType<typeof setInterval>; | |
| 237 | + | let cleanupListener: (() => void) | undefined; | |
| 218 | 238 | ||
| 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)); | |
| 228 | 257 | ||
| 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 | + | }); | |
| 233 | 262 | ||
| 234 | - | // Invalidate buddy list cache when user status changes | |
| 235 | - | buddyListCache.invalidate(); | |
| 263 | + | buddyListCache.invalidate(); | |
| 236 | 264 | ||
| 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 }); | |
| 243 | 272 | } | |
| 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 | + | } | |
| 254 | 274 | ||
| 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) => { | |
| 258 | 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 | 304 | 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); | |
| 263 | 309 | } | |
| 264 | 310 | } catch (error) { | |
| 265 | 311 | log.error('Error sending event', { error }); | |
| 266 | 312 | } | |
| 267 | 313 | }; | |
| 268 | 314 | ||
| 269 | - | onSSE = (event: { type: string; data: unknown }) => { | |
| 270 | - | sendEvent(event); | |
| 271 | - | }; | |
| 315 | + | cleanupListener = sseEmitter.addListener('sse', onSSE); | |
| 272 | 316 | ||
| 273 | - | sseEmitter.addListener('sse', onSSE); | |
| 317 | + | // Handle abort signal | |
| 274 | 318 | request.signal.addEventListener('abort', () => { | |
| 275 | - | if (onSSE) { | |
| 276 | - | sseEmitter.removeListener('sse', onSSE); | |
| 319 | + | if (cleanupListener) { | |
| 320 | + | cleanupListener(); | |
| 277 | 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 | 331 | 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); | |
| 287 | 338 | }, | |
| 339 | + | ||
| 288 | 340 | async cancel(reason) { | |
| 289 | 341 | 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(); | |
| 293 | 343 | ||
| 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 | + | }); | |
| 296 | 350 | ||
| 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) { | |
| 299 | 356 | 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)); | |
| 304 | 362 | ||
| 305 | - | // Invalidate buddy list cache when user goes offline | |
| 306 | - | buddyListCache.invalidate(); | |
| 363 | + | buddyListCache.invalidate(); | |
| 307 | 364 | ||
| 308 | - | sseEmitter.emit('sse', { | |
| 309 | - | type: 'userStatusUpdate', | |
| 310 | - | data: { | |
| 365 | + | sseEmitter.emit('userStatusUpdate', { | |
| 311 | 366 | userId, | |
| 312 | 367 | status: 'offline', | |
| 313 | 368 | lastSeen: now | |
| 314 | - | } | |
| 315 | - | }); | |
| 369 | + | }); | |
| 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 | 378 | headers: { | |
| 322 | 379 | 'Content-Type': 'text/event-stream', | |
| 323 | 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 | }; | |
| @@ -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 | + | ||||
| 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 constant | 31 | + | // Default chat room ID |
| 25 | - | const BUDDY_LIST_BROADCAST_INTERVAL = 10000; // 10 seconds | 32 | + | 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 updates | 40 | + | // 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 users | 44 | + | /** |
| 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: now | 65 | 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 update | 108 | + | 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: cachedData | 127 | + | 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 seconds | 136 | + | }, 1000); |
| 128 | } | 137 | } | ||
| 129 | 138 | ||||
| 130 | - | // Add periodic cleanup for stale connections | 139 | + | /** |
| 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 offline | 150 | + | 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 minute | 153 | + | }, 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 reloaded | 168 | + | // 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 SIGINT | 177 | + | 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 connection | 235 | + | 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 db | 241 | + | // 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: now | 245 | + | 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 changes | 263 | + | 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: now | 270 | + | } 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 tracker | 344 | + | 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 connection | 351 | + | // 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 db | 357 | + | 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 offline | 363 | + | 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: now | 368 | 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 | + | }; | |||