feat(api): add heartbeat endpoint and refactor SSE handler
- Add POST /api/sse/heartbeat endpoint for connection keep-alive - Refactor main SSE handler (/api/sse) to use new modular architecture - Improve error handling and connection lifecycle management - Simplify message routing and event handling
- commit
- 0270c678830ef72fc5d4f9e77857e64cedb2a74d
- author
- Kit · · 2025-12-15 10:21 UTC
- parent
- 78e1985
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
2 files · +270 -145
M src/routes/api/sse/+server.ts +204 -145
| @@ -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() | |
| @@ -54,8 +66,8 @@ function startTimeoutCheck() { | |||
| 54 | 66 | }) | |
| 55 | 67 | .where( | |
| 56 | 68 | and( | |
| 57 | - | ne(users.status, 'offline'), // Update any non-offline users | |
| 58 | - | lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently | |
| 69 | + | ne(users.status, 'offline'), | |
| 70 | + | lt(users.lastSeen, timeoutThreshold) | |
| 59 | 71 | ) | |
| 60 | 72 | ) | |
| 61 | 73 | .returning({ | |
| @@ -65,20 +77,31 @@ function startTimeoutCheck() { | |||
| 65 | 77 | }); | |
| 66 | 78 | ||
| 67 | 79 | if (result.length > 0) { | |
| 68 | - | log.info('User status updated to offline', { | |
| 80 | + | log.info('User status updated to offline due to timeout', { | |
| 69 | 81 | usersUpdated: result.length, | |
| 70 | 82 | timeoutThreshold: new Date(timeoutThreshold).toISOString() | |
| 71 | 83 | }); | |
| 72 | 84 | ||
| 73 | - | // Invalidate buddy list cache when users timeout | |
| 74 | 85 | buddyListCache.invalidate(); | |
| 86 | + | ||
| 87 | + | // Broadcast status updates for each timed out user | |
| 88 | + | for (const user of result) { | |
| 89 | + | sseEmitter.emit('userStatusUpdate', { | |
| 90 | + | userId: user.id, | |
| 91 | + | status: 'offline', | |
| 92 | + | lastSeen: now | |
| 93 | + | }); | |
| 94 | + | } | |
| 75 | 95 | } | |
| 76 | 96 | } catch (error) { | |
| 77 | 97 | log.error('Error during timeout check', { error }); | |
| 78 | 98 | } | |
| 79 | - | }, CHECK_INTERVAL_MS); | |
| 99 | + | }, SSE_CONFIG.STALE_CHECK_INTERVAL); | |
| 80 | 100 | } | |
| 81 | 101 | ||
| 102 | + | /** | |
| 103 | + | * Start buddy list broadcast interval | |
| 104 | + | */ | |
| 82 | 105 | function startBuddyListUpdateInterval() { | |
| 83 | 106 | if (buddyListInterval) { | |
| 84 | 107 | clearInterval(buddyListInterval); | |
| @@ -87,36 +110,27 @@ function startBuddyListUpdateInterval() { | |||
| 87 | 110 | buddyListInterval = setInterval(async () => { | |
| 88 | 111 | try { | |
| 89 | 112 | 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 | |
| 113 | + | if (now - lastBuddyListUpdate >= SSE_CONFIG.BUDDY_LIST_BROADCAST_INTERVAL) { | |
| 93 | 114 | if (buddyListCache.needsRefresh()) { | |
| 94 | 115 | log.debug('Fetching buddy list from database (cache miss)...'); | |
| 95 | - | // Query the complete buddy list from the database. | |
| 96 | 116 | const buddyList = await db.select().from(users); | |
| 97 | - | // Use createSafeUser to properly sanitize user data | |
| 98 | 117 | const safeBuddyList = buddyList.map((user) => createSafeUser(user)); | |
| 99 | 118 | ||
| 100 | - | // Update cache and check if data changed | |
| 101 | 119 | const hasChanged = buddyListCache.update(safeBuddyList); | |
| 102 | 120 | ||
| 103 | 121 | if (hasChanged) { | |
| 104 | 122 | log.debug('Broadcasting buddy list update (data changed)...'); | |
| 105 | - | sseEmitter.emit('sse', { | |
| 106 | - | type: 'buddyListUpdate', | |
| 107 | - | data: safeBuddyList | |
| 108 | - | }); | |
| 123 | + | sseEmitter.emit('buddyListUpdate', safeBuddyList); | |
| 109 | 124 | } | |
| 110 | 125 | } else { | |
| 111 | - | // Use cached data | |
| 112 | 126 | 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 | - | }); | |
| 127 | + | if (cachedData) { | |
| 128 | + | const currentHash = JSON.stringify(cachedData); | |
| 129 | + | if (currentHash !== lastBuddyListHash) { | |
| 130 | + | log.debug('Broadcasting buddy list update from cache...'); | |
| 131 | + | lastBuddyListHash = currentHash; | |
| 132 | + | sseEmitter.emit('buddyListUpdate', cachedData); | |
| 133 | + | } | |
| 120 | 134 | } | |
| 121 | 135 | } | |
| 122 | 136 | lastBuddyListUpdate = now; | |
| @@ -124,72 +138,78 @@ function startBuddyListUpdateInterval() { | |||
| 124 | 138 | } catch (error) { | |
| 125 | 139 | log.error('Error fetching buddy list for update', { error }); | |
| 126 | 140 | } | |
| 127 | - | }, 1000); // Check every second but only broadcast every 10 seconds | |
| 141 | + | }, 1000); | |
| 128 | 142 | } | |
| 129 | 143 | ||
| 130 | - | // Add periodic cleanup for stale connections | |
| 131 | - | let connectionCleanupInterval: ReturnType<typeof setInterval>; | |
| 132 | - | ||
| 144 | + | /** | |
| 145 | + | * Start periodic cleanup for stale connections | |
| 146 | + | */ | |
| 133 | 147 | function startConnectionCleanup() { | |
| 134 | 148 | if (connectionCleanupInterval) { | |
| 135 | 149 | clearInterval(connectionCleanupInterval); | |
| 136 | 150 | } | |
| 137 | 151 | ||
| 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 | - | }); | |
| 152 | + | connectionCleanupInterval = setInterval(async () => { | |
| 153 | + | // Get stats and log them periodically | |
| 154 | + | const stats = sseManager.getStats(); | |
| 155 | + | if (stats.totalConnections > 0) { | |
| 156 | + | log.debug('SSE connection stats', stats); | |
| 154 | 157 | } | |
| 155 | - | }, 60000); // Run every minute | |
| 158 | + | }, SSE_CONFIG.STALE_CHECK_INTERVAL); | |
| 156 | 159 | } | |
| 157 | 160 | ||
| 158 | - | // <<< NEW: Ensure intervals are started only once >>> | |
| 161 | + | // Start background tasks only once | |
| 159 | 162 | if (!globalThis.__sseIntervalsStarted) { | |
| 160 | 163 | startTimeoutCheck(); | |
| 161 | 164 | startBuddyListUpdateInterval(); | |
| 162 | 165 | startConnectionCleanup(); | |
| 166 | + | sseManager.startBackgroundTasks(); | |
| 163 | 167 | globalThis.__sseIntervalsStarted = true; | |
| 164 | - | log.info('Started global intervals for timeout and buddy list updates'); | |
| 168 | + | log.info('Started global intervals for SSE management'); | |
| 165 | 169 | } else { | |
| 166 | 170 | log.debug('Global intervals already started, skipping initialization'); | |
| 167 | 171 | } | |
| 168 | 172 | ||
| 169 | - | // Ensure the interval is cleared if the module is reloaded | |
| 173 | + | // Cleanup on process exit | |
| 170 | 174 | if (typeof process !== 'undefined') { | |
| 171 | - | process.on('beforeExit', () => { | |
| 175 | + | const cleanup = () => { | |
| 172 | 176 | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 173 | 177 | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 174 | 178 | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 175 | - | }); | |
| 179 | + | sseManager.stopBackgroundTasks(); | |
| 180 | + | }; | |
| 176 | 181 | ||
| 177 | - | // Also handle SIGTERM and SIGINT | |
| 182 | + | process.on('beforeExit', cleanup); | |
| 178 | 183 | process.on('SIGTERM', () => { | |
| 179 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 180 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 181 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 184 | + | cleanup(); | |
| 182 | 185 | process.exit(0); | |
| 183 | 186 | }); | |
| 184 | - | ||
| 185 | 187 | process.on('SIGINT', () => { | |
| 186 | - | if (timeoutInterval) clearInterval(timeoutInterval); | |
| 187 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |
| 188 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |
| 188 | + | cleanup(); | |
| 189 | 189 | process.exit(0); | |
| 190 | 190 | }); | |
| 191 | 191 | } | |
| 192 | 192 | ||
| 193 | + | /** | |
| 194 | + | * Format an SSE event with ID and retry field | |
| 195 | + | */ | |
| 196 | + | function formatSSEEvent(event: SSEEvent, eventId: string): string { | |
| 197 | + | const lines = [ | |
| 198 | + | `id: ${eventId}`, | |
| 199 | + | `event: ${event.type}`, | |
| 200 | + | `data: ${JSON.stringify(event.data)}`, | |
| 201 | + | `retry: ${SSE_CONFIG.RETRY_INTERVAL}`, | |
| 202 | + | '', | |
| 203 | + | '' | |
| 204 | + | ]; | |
| 205 | + | return lines.join('\n'); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /** | |
| 209 | + | * GET /api/sse | |
| 210 | + | * | |
| 211 | + | * Establishes an SSE connection for real-time updates. | |
| 212 | + | */ | |
| 193 | 213 | export const GET: RequestHandler = async ({ request, locals }) => { | |
| 194 | 214 | log.debug('Processing SSE request', { | |
| 195 | 215 | url: request.url, | |
| @@ -200,119 +220,157 @@ export const GET: RequestHandler = async ({ request, locals }) => { | |||
| 200 | 220 | log.warn('Authentication failed - no user in locals'); | |
| 201 | 221 | return new Response('Unauthorized', { | |
| 202 | 222 | status: 401, | |
| 203 | - | headers: { | |
| 204 | - | 'Content-Type': 'application/json' | |
| 205 | - | } | |
| 223 | + | headers: { 'Content-Type': 'application/json' } | |
| 206 | 224 | }); | |
| 207 | 225 | } | |
| 208 | 226 | ||
| 209 | 227 | const userId = locals.user.id; | |
| 228 | + | const roomId = DEFAULT_CHAT_ROOM_ID; | |
| 210 | 229 | const encoder = new TextEncoder(); | |
| 211 | - | let keepAliveInterval: ReturnType<typeof setInterval>; | |
| 212 | - | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | |
| 213 | 230 | ||
| 214 | - | const now = Date.now(); | |
| 231 | + | // Check for Last-Event-ID header for catch-up | |
| 232 | + | const lastEventId = request.headers.get('Last-Event-ID'); | |
| 233 | + | if (lastEventId) { | |
| 234 | + | log.debug('Client reconnecting with Last-Event-ID', { | |
| 235 | + | userId: userId.slice(0, 8), | |
| 236 | + | lastEventId: lastEventId.slice(0, 8) | |
| 237 | + | }); | |
| 238 | + | } | |
| 215 | 239 | ||
| 216 | - | // Only update status if this is a new connection | |
| 217 | - | const isNewConnection = sseConnectionTracker.addConnection(userId); | |
| 240 | + | let connectionId: string; | |
| 241 | + | let keepAliveInterval: ReturnType<typeof setInterval>; | |
| 242 | + | let cleanupListener: (() => void) | undefined; | |
| 218 | 243 | ||
| 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)); | |
| 244 | + | const stream = new ReadableStream({ | |
| 245 | + | async start(controller) { | |
| 246 | + | // Add connection to manager | |
| 247 | + | const { connectionId: connId, isNewUser } = sseManager.addConnection( | |
| 248 | + | userId, | |
| 249 | + | roomId, | |
| 250 | + | controller | |
| 251 | + | ); | |
| 252 | + | connectionId = connId; | |
| 253 | + | ||
| 254 | + | // Update user status if new connection | |
| 255 | + | if (isNewUser) { | |
| 256 | + | const now = Date.now(); | |
| 257 | + | try { | |
| 258 | + | await db | |
| 259 | + | .update(users) | |
| 260 | + | .set({ status: 'online', lastSeen: now }) | |
| 261 | + | .where(eq(users.id, userId)); | |
| 228 | 262 | ||
| 229 | - | log.info('User status updated to online', { | |
| 230 | - | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, | |
| 231 | - | timestamp: new Date(now).toISOString() | |
| 232 | - | }); | |
| 263 | + | log.info('User status updated to online', { | |
| 264 | + | userId: userId.slice(0, 8), | |
| 265 | + | connectionId: connectionId.slice(0, 8) | |
| 266 | + | }); | |
| 233 | 267 | ||
| 234 | - | // Invalidate buddy list cache when user status changes | |
| 235 | - | buddyListCache.invalidate(); | |
| 268 | + | buddyListCache.invalidate(); | |
| 236 | 269 | ||
| 237 | - | sseEmitter.emit('sse', { | |
| 238 | - | type: 'userStatusUpdate', | |
| 239 | - | data: { | |
| 240 | - | userId, | |
| 241 | - | status: 'online', | |
| 242 | - | lastSeen: now | |
| 270 | + | sseEmitter.emit('userStatusUpdate', { | |
| 271 | + | userId, | |
| 272 | + | status: 'online', | |
| 273 | + | lastSeen: now | |
| 274 | + | }); | |
| 275 | + | } catch (error) { | |
| 276 | + | log.error('Error updating user status', { error }); | |
| 243 | 277 | } | |
| 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 | - | } | |
| 278 | + | } | |
| 254 | 279 | ||
| 255 | - | const stream = new ReadableStream({ | |
| 256 | - | start(controller) { | |
| 257 | - | const sendEvent = (event: { type: string; data: unknown }) => { | |
| 280 | + | // Send initial connection event | |
| 281 | + | const connectEvent = formatSSEEvent( | |
| 282 | + | { type: 'userStatusUpdate', data: { userId, status: 'connected', lastSeen: Date.now() } }, | |
| 283 | + | crypto.randomUUID() | |
| 284 | + | ); | |
| 285 | + | controller.enqueue(encoder.encode(connectEvent)); | |
| 286 | + | ||
| 287 | + | // Send catch-up messages if reconnecting with Last-Event-ID | |
| 288 | + | if (lastEventId) { | |
| 289 | + | const missedMessages = messageQueue.getMessagesSince(roomId, lastEventId); | |
| 290 | + | if (missedMessages.length > 0) { | |
| 291 | + | log.info('Sending catch-up messages', { | |
| 292 | + | userId: userId.slice(0, 8), | |
| 293 | + | count: missedMessages.length | |
| 294 | + | }); | |
| 295 | + | for (const msg of missedMessages) { | |
| 296 | + | // Cast to SSEEvent - the data was already validated when queued | |
| 297 | + | const event = { type: msg.event, data: msg.data } as SSEEvent; | |
| 298 | + | const payload = formatSSEEvent(event, msg.id); | |
| 299 | + | controller.enqueue(encoder.encode(payload)); | |
| 300 | + | } | |
| 301 | + | } | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // Listen for SSE events | |
| 305 | + | const onSSE = (event: SSEEvent) => { | |
| 258 | 306 | try { | |
| 259 | - | const payload = `event: ${event.type}\n` + `data: ${JSON.stringify(event.data)}\n\n`; | |
| 307 | + | const eventId = crypto.randomUUID(); | |
| 308 | + | const payload = formatSSEEvent(event, eventId); | |
| 260 | 309 | controller.enqueue(encoder.encode(payload)); | |
| 261 | - | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | |
| 262 | - | log.info('Event sent', { type: event.type }); | |
| 310 | + | ||
| 311 | + | // Queue message for catch-up (if not heartbeat) | |
| 312 | + | if (event.type !== 'heartbeat') { | |
| 313 | + | messageQueue.enqueue(roomId, event.type, event.data); | |
| 263 | 314 | } | |
| 264 | 315 | } catch (error) { | |
| 265 | 316 | log.error('Error sending event', { error }); | |
| 266 | 317 | } | |
| 267 | 318 | }; | |
| 268 | 319 | ||
| 269 | - | onSSE = (event: { type: string; data: unknown }) => { | |
| 270 | - | sendEvent(event); | |
| 271 | - | }; | |
| 320 | + | cleanupListener = sseEmitter.addListener('sse', onSSE); | |
| 272 | 321 | ||
| 273 | - | sseEmitter.addListener('sse', onSSE); | |
| 322 | + | // Handle abort signal | |
| 274 | 323 | request.signal.addEventListener('abort', () => { | |
| 275 | - | if (onSSE) { | |
| 276 | - | sseEmitter.removeListener('sse', onSSE); | |
| 324 | + | if (cleanupListener) { | |
| 325 | + | cleanupListener(); | |
| 277 | 326 | log.info('Removed listener due to abort signal'); | |
| 278 | 327 | } | |
| 279 | 328 | }); | |
| 280 | - | log.info('Added new listener'); | |
| 281 | 329 | ||
| 282 | - | controller.enqueue(encoder.encode('data: Connected\n\n')); | |
| 330 | + | log.info('SSE connection established', { | |
| 331 | + | connectionId: connectionId.slice(0, 8), | |
| 332 | + | userId: userId.slice(0, 8) | |
| 333 | + | }); | |
| 283 | 334 | ||
| 335 | + | // Keep-alive pings (in addition to heartbeats from manager) | |
| 284 | 336 | keepAliveInterval = setInterval(() => { | |
| 285 | - | controller.enqueue(encoder.encode(':\n\n')); | |
| 286 | - | }, 20000); | |
| 337 | + | try { | |
| 338 | + | controller.enqueue(encoder.encode(':\n\n')); | |
| 339 | + | } catch { | |
| 340 | + | // Connection might be closed | |
| 341 | + | } | |
| 342 | + | }, SSE_CONFIG.HEARTBEAT_INTERVAL); | |
| 287 | 343 | }, | |
| 344 | + | ||
| 288 | 345 | async cancel(reason) { | |
| 289 | 346 | 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 }); | |
| 347 | + | if (cleanupListener) cleanupListener(); | |
| 293 | 348 | ||
| 294 | - | // Remove from connection tracker | |
| 295 | - | const wasTracked = sseConnectionTracker.removeConnection(userId); | |
| 349 | + | const maskedUserId = userId.slice(0, 8); | |
| 350 | + | log.info('Connection closed', { userId: maskedUserId, connectionId: connectionId?.slice(0, 8), reason }); | |
| 296 | 351 | ||
| 297 | - | // Only update database if this was the last connection | |
| 298 | - | if (wasTracked && !sseConnectionTracker.hasConnection(userId)) { | |
| 352 | + | // Remove connection from manager | |
| 353 | + | const { isLastConnection } = sseManager.removeConnection(connectionId); | |
| 354 | + | ||
| 355 | + | // Update database if last connection for this user | |
| 356 | + | if (isLastConnection) { | |
| 299 | 357 | const now = Date.now(); | |
| 300 | - | await db | |
| 301 | - | .update(users) | |
| 302 | - | .set({ status: 'offline', lastSeen: now }) | |
| 303 | - | .where(eq(users.id, userId)); | |
| 358 | + | try { | |
| 359 | + | await db | |
| 360 | + | .update(users) | |
| 361 | + | .set({ status: 'offline', lastSeen: now }) | |
| 362 | + | .where(eq(users.id, userId)); | |
| 304 | 363 | ||
| 305 | - | // Invalidate buddy list cache when user goes offline | |
| 306 | - | buddyListCache.invalidate(); | |
| 364 | + | buddyListCache.invalidate(); | |
| 307 | 365 | ||
| 308 | - | sseEmitter.emit('sse', { | |
| 309 | - | type: 'userStatusUpdate', | |
| 310 | - | data: { | |
| 366 | + | sseEmitter.emit('userStatusUpdate', { | |
| 311 | 367 | userId, | |
| 312 | 368 | status: 'offline', | |
| 313 | 369 | lastSeen: now | |
| 314 | - | } | |
| 315 | - | }); | |
| 370 | + | }); | |
| 371 | + | } catch (error) { | |
| 372 | + | log.error('Error updating user status on disconnect', { error }); | |
| 373 | + | } | |
| 316 | 374 | } | |
| 317 | 375 | } | |
| 318 | 376 | }); | |
| @@ -321,7 +379,8 @@ export const GET: RequestHandler = async ({ request, locals }) => { | |||
| 321 | 379 | headers: { | |
| 322 | 380 | 'Content-Type': 'text/event-stream', | |
| 323 | 381 | 'Cache-Control': 'no-cache', | |
| 324 | - | Connection: 'keep-alive' | |
| 382 | + | 'Connection': 'keep-alive', | |
| 383 | + | 'X-Accel-Buffering': 'no' // Disable nginx buffering | |
| 325 | 384 | } | |
| 326 | 385 | }); | |
| 327 | 386 | }; | |
| @@ -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() | ||
| @@ -54,8 +66,8 @@ function startTimeoutCheck() { | |||||
| 54 | }) | 66 | }) | ||
| 55 | .where( | 67 | .where( | ||
| 56 | and( | 68 | and( | ||
| 57 | - | ne(users.status, 'offline'), // Update any non-offline users | 69 | + | ne(users.status, 'offline'), |
| 58 | - | lt(users.lastSeen, timeoutThreshold) // Who haven't been seen recently | 70 | + | lt(users.lastSeen, timeoutThreshold) |
| 59 | ) | 71 | ) | ||
| 60 | ) | 72 | ) | ||
| 61 | .returning({ | 73 | .returning({ | ||
| @@ -65,20 +77,31 @@ function startTimeoutCheck() { | |||||
| 65 | }); | 77 | }); | ||
| 66 | 78 | ||||
| 67 | if (result.length > 0) { | 79 | if (result.length > 0) { | ||
| 68 | - | log.info('User status updated to offline', { | 80 | + | log.info('User status updated to offline due to timeout', { |
| 69 | usersUpdated: result.length, | 81 | usersUpdated: result.length, | ||
| 70 | timeoutThreshold: new Date(timeoutThreshold).toISOString() | 82 | timeoutThreshold: new Date(timeoutThreshold).toISOString() | ||
| 71 | }); | 83 | }); | ||
| 72 | 84 | ||||
| 73 | - | // Invalidate buddy list cache when users timeout | |||
| 74 | buddyListCache.invalidate(); | 85 | buddyListCache.invalidate(); | ||
| 86 | + | ||||
| 87 | + | // Broadcast status updates for each timed out user | |||
| 88 | + | for (const user of result) { | |||
| 89 | + | sseEmitter.emit('userStatusUpdate', { | |||
| 90 | + | userId: user.id, | |||
| 91 | + | status: 'offline', | |||
| 92 | + | lastSeen: now | |||
| 93 | + | }); | |||
| 94 | + | } | |||
| 75 | } | 95 | } | ||
| 76 | } catch (error) { | 96 | } catch (error) { | ||
| 77 | log.error('Error during timeout check', { error }); | 97 | log.error('Error during timeout check', { error }); | ||
| 78 | } | 98 | } | ||
| 79 | - | }, CHECK_INTERVAL_MS); | 99 | + | }, SSE_CONFIG.STALE_CHECK_INTERVAL); |
| 80 | } | 100 | } | ||
| 81 | 101 | ||||
| 102 | + | /** | |||
| 103 | + | * Start buddy list broadcast interval | |||
| 104 | + | */ | |||
| 82 | function startBuddyListUpdateInterval() { | 105 | function startBuddyListUpdateInterval() { | ||
| 83 | if (buddyListInterval) { | 106 | if (buddyListInterval) { | ||
| 84 | clearInterval(buddyListInterval); | 107 | clearInterval(buddyListInterval); | ||
| @@ -87,36 +110,27 @@ function startBuddyListUpdateInterval() { | |||||
| 87 | buddyListInterval = setInterval(async () => { | 110 | buddyListInterval = setInterval(async () => { | ||
| 88 | try { | 111 | try { | ||
| 89 | const now = Date.now(); | 112 | const now = Date.now(); | ||
| 90 | - | // Only fetch and broadcast if 10 seconds have passed since last update | 113 | + | 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()) { | 114 | if (buddyListCache.needsRefresh()) { | ||
| 94 | log.debug('Fetching buddy list from database (cache miss)...'); | 115 | 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); | 116 | const buddyList = await db.select().from(users); | ||
| 97 | - | // Use createSafeUser to properly sanitize user data | |||
| 98 | const safeBuddyList = buddyList.map((user) => createSafeUser(user)); | 117 | const safeBuddyList = buddyList.map((user) => createSafeUser(user)); | ||
| 99 | 118 | ||||
| 100 | - | // Update cache and check if data changed | |||
| 101 | const hasChanged = buddyListCache.update(safeBuddyList); | 119 | const hasChanged = buddyListCache.update(safeBuddyList); | ||
| 102 | 120 | ||||
| 103 | if (hasChanged) { | 121 | if (hasChanged) { | ||
| 104 | log.debug('Broadcasting buddy list update (data changed)...'); | 122 | log.debug('Broadcasting buddy list update (data changed)...'); | ||
| 105 | - | sseEmitter.emit('sse', { | 123 | + | sseEmitter.emit('buddyListUpdate', safeBuddyList); |
| 106 | - | type: 'buddyListUpdate', | |||
| 107 | - | data: safeBuddyList | |||
| 108 | - | }); | |||
| 109 | } | 124 | } | ||
| 110 | } else { | 125 | } else { | ||
| 111 | - | // Use cached data | |||
| 112 | const cachedData = buddyListCache.get(); | 126 | const cachedData = buddyListCache.get(); | ||
| 113 | - | if (cachedData && JSON.stringify(cachedData) !== JSON.stringify(lastBuddyListData)) { | 127 | + | if (cachedData) { |
| 114 | - | log.debug('Broadcasting buddy list update from cache...'); | 128 | + | const currentHash = JSON.stringify(cachedData); |
| 115 | - | lastBuddyListData = cachedData; | 129 | + | if (currentHash !== lastBuddyListHash) { |
| 116 | - | sseEmitter.emit('sse', { | 130 | + | log.debug('Broadcasting buddy list update from cache...'); |
| 117 | - | type: 'buddyListUpdate', | 131 | + | lastBuddyListHash = currentHash; |
| 118 | - | data: cachedData | 132 | + | sseEmitter.emit('buddyListUpdate', cachedData); |
| 119 | - | }); | 133 | + | } |
| 120 | } | 134 | } | ||
| 121 | } | 135 | } | ||
| 122 | lastBuddyListUpdate = now; | 136 | lastBuddyListUpdate = now; | ||
| @@ -124,72 +138,78 @@ function startBuddyListUpdateInterval() { | |||||
| 124 | } catch (error) { | 138 | } catch (error) { | ||
| 125 | log.error('Error fetching buddy list for update', { error }); | 139 | log.error('Error fetching buddy list for update', { error }); | ||
| 126 | } | 140 | } | ||
| 127 | - | }, 1000); // Check every second but only broadcast every 10 seconds | 141 | + | }, 1000); |
| 128 | } | 142 | } | ||
| 129 | 143 | ||||
| 130 | - | // Add periodic cleanup for stale connections | 144 | + | /** |
| 131 | - | let connectionCleanupInterval: ReturnType<typeof setInterval>; | 145 | + | * Start periodic cleanup for stale connections |
| 132 | - | 146 | + | */ | |
| 133 | function startConnectionCleanup() { | 147 | function startConnectionCleanup() { | ||
| 134 | if (connectionCleanupInterval) { | 148 | if (connectionCleanupInterval) { | ||
| 135 | clearInterval(connectionCleanupInterval); | 149 | clearInterval(connectionCleanupInterval); | ||
| 136 | } | 150 | } | ||
| 137 | 151 | ||||
| 138 | - | connectionCleanupInterval = setInterval(() => { | 152 | + | connectionCleanupInterval = setInterval(async () => { |
| 139 | - | const staleUserIds = sseConnectionTracker.cleanupStaleConnections(); | 153 | + | // Get stats and log them periodically |
| 140 | - | if (staleUserIds.length > 0) { | 154 | + | const stats = sseManager.getStats(); |
| 141 | - | // Mark stale connections as offline | 155 | + | if (stats.totalConnections > 0) { |
| 142 | - | staleUserIds.forEach(async (userId) => { | 156 | + | 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 | } | 157 | } | ||
| 155 | - | }, 60000); // Run every minute | 158 | + | }, SSE_CONFIG.STALE_CHECK_INTERVAL); |
| 156 | } | 159 | } | ||
| 157 | 160 | ||||
| 158 | - | // <<< NEW: Ensure intervals are started only once >>> | 161 | + | // Start background tasks only once |
| 159 | if (!globalThis.__sseIntervalsStarted) { | 162 | if (!globalThis.__sseIntervalsStarted) { | ||
| 160 | startTimeoutCheck(); | 163 | startTimeoutCheck(); | ||
| 161 | startBuddyListUpdateInterval(); | 164 | startBuddyListUpdateInterval(); | ||
| 162 | startConnectionCleanup(); | 165 | startConnectionCleanup(); | ||
| 166 | + | sseManager.startBackgroundTasks(); | |||
| 163 | globalThis.__sseIntervalsStarted = true; | 167 | globalThis.__sseIntervalsStarted = true; | ||
| 164 | - | log.info('Started global intervals for timeout and buddy list updates'); | 168 | + | log.info('Started global intervals for SSE management'); |
| 165 | } else { | 169 | } else { | ||
| 166 | log.debug('Global intervals already started, skipping initialization'); | 170 | log.debug('Global intervals already started, skipping initialization'); | ||
| 167 | } | 171 | } | ||
| 168 | 172 | ||||
| 169 | - | // Ensure the interval is cleared if the module is reloaded | 173 | + | // Cleanup on process exit |
| 170 | if (typeof process !== 'undefined') { | 174 | if (typeof process !== 'undefined') { | ||
| 171 | - | process.on('beforeExit', () => { | 175 | + | const cleanup = () => { |
| 172 | if (timeoutInterval) clearInterval(timeoutInterval); | 176 | if (timeoutInterval) clearInterval(timeoutInterval); | ||
| 173 | if (buddyListInterval) clearInterval(buddyListInterval); | 177 | if (buddyListInterval) clearInterval(buddyListInterval); | ||
| 174 | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | 178 | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | ||
| 175 | - | }); | 179 | + | sseManager.stopBackgroundTasks(); |
| 180 | + | }; | |||
| 176 | 181 | ||||
| 177 | - | // Also handle SIGTERM and SIGINT | 182 | + | process.on('beforeExit', cleanup); |
| 178 | process.on('SIGTERM', () => { | 183 | process.on('SIGTERM', () => { | ||
| 179 | - | if (timeoutInterval) clearInterval(timeoutInterval); | 184 | + | cleanup(); |
| 180 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |||
| 181 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |||
| 182 | process.exit(0); | 185 | process.exit(0); | ||
| 183 | }); | 186 | }); | ||
| 184 | - | ||||
| 185 | process.on('SIGINT', () => { | 187 | process.on('SIGINT', () => { | ||
| 186 | - | if (timeoutInterval) clearInterval(timeoutInterval); | 188 | + | cleanup(); |
| 187 | - | if (buddyListInterval) clearInterval(buddyListInterval); | |||
| 188 | - | if (connectionCleanupInterval) clearInterval(connectionCleanupInterval); | |||
| 189 | process.exit(0); | 189 | process.exit(0); | ||
| 190 | }); | 190 | }); | ||
| 191 | } | 191 | } | ||
| 192 | 192 | ||||
| 193 | + | /** | |||
| 194 | + | * Format an SSE event with ID and retry field | |||
| 195 | + | */ | |||
| 196 | + | function formatSSEEvent(event: SSEEvent, eventId: string): string { | |||
| 197 | + | const lines = [ | |||
| 198 | + | `id: ${eventId}`, | |||
| 199 | + | `event: ${event.type}`, | |||
| 200 | + | `data: ${JSON.stringify(event.data)}`, | |||
| 201 | + | `retry: ${SSE_CONFIG.RETRY_INTERVAL}`, | |||
| 202 | + | '', | |||
| 203 | + | '' | |||
| 204 | + | ]; | |||
| 205 | + | return lines.join('\n'); | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | /** | |||
| 209 | + | * GET /api/sse | |||
| 210 | + | * | |||
| 211 | + | * Establishes an SSE connection for real-time updates. | |||
| 212 | + | */ | |||
| 193 | export const GET: RequestHandler = async ({ request, locals }) => { | 213 | export const GET: RequestHandler = async ({ request, locals }) => { | ||
| 194 | log.debug('Processing SSE request', { | 214 | log.debug('Processing SSE request', { | ||
| 195 | url: request.url, | 215 | url: request.url, | ||
| @@ -200,119 +220,157 @@ export const GET: RequestHandler = async ({ request, locals }) => { | |||||
| 200 | log.warn('Authentication failed - no user in locals'); | 220 | log.warn('Authentication failed - no user in locals'); | ||
| 201 | return new Response('Unauthorized', { | 221 | return new Response('Unauthorized', { | ||
| 202 | status: 401, | 222 | status: 401, | ||
| 203 | - | headers: { | 223 | + | headers: { 'Content-Type': 'application/json' } |
| 204 | - | 'Content-Type': 'application/json' | |||
| 205 | - | } | |||
| 206 | }); | 224 | }); | ||
| 207 | } | 225 | } | ||
| 208 | 226 | ||||
| 209 | const userId = locals.user.id; | 227 | const userId = locals.user.id; | ||
| 228 | + | const roomId = DEFAULT_CHAT_ROOM_ID; | |||
| 210 | const encoder = new TextEncoder(); | 229 | const encoder = new TextEncoder(); | ||
| 211 | - | let keepAliveInterval: ReturnType<typeof setInterval>; | |||
| 212 | - | let onSSE: ((event: { type: string; data: unknown }) => void) | undefined; | |||
| 213 | 230 | ||||
| 214 | - | const now = Date.now(); | 231 | + | // Check for Last-Event-ID header for catch-up |
| 232 | + | const lastEventId = request.headers.get('Last-Event-ID'); | |||
| 233 | + | if (lastEventId) { | |||
| 234 | + | log.debug('Client reconnecting with Last-Event-ID', { | |||
| 235 | + | userId: userId.slice(0, 8), | |||
| 236 | + | lastEventId: lastEventId.slice(0, 8) | |||
| 237 | + | }); | |||
| 238 | + | } | |||
| 215 | 239 | ||||
| 216 | - | // Only update status if this is a new connection | 240 | + | let connectionId: string; |
| 217 | - | const isNewConnection = sseConnectionTracker.addConnection(userId); | 241 | + | let keepAliveInterval: ReturnType<typeof setInterval>; |
| 242 | + | let cleanupListener: (() => void) | undefined; | |||
| 218 | 243 | ||||
| 219 | - | if (isNewConnection) { | 244 | + | const stream = new ReadableStream({ |
| 220 | - | try { | 245 | + | async start(controller) { |
| 221 | - | await db | 246 | + | // Add connection to manager |
| 222 | - | .update(users) | 247 | + | const { connectionId: connId, isNewUser } = sseManager.addConnection( |
| 223 | - | .set({ | 248 | + | userId, |
| 224 | - | status: 'online', | 249 | + | roomId, |
| 225 | - | lastSeen: now | 250 | + | controller |
| 226 | - | }) | 251 | + | ); |
| 227 | - | .where(eq(users.id, userId)); | 252 | + | connectionId = connId; |
| 253 | + | ||||
| 254 | + | // Update user status if new connection | |||
| 255 | + | if (isNewUser) { | |||
| 256 | + | const now = Date.now(); | |||
| 257 | + | try { | |||
| 258 | + | await db | |||
| 259 | + | .update(users) | |||
| 260 | + | .set({ status: 'online', lastSeen: now }) | |||
| 261 | + | .where(eq(users.id, userId)); | |||
| 228 | 262 | ||||
| 229 | - | log.info('User status updated to online', { | 263 | + | log.info('User status updated to online', { |
| 230 | - | userId: `${userId.slice(0, 4)}...${userId.slice(-4)}`, | 264 | + | userId: userId.slice(0, 8), |
| 231 | - | timestamp: new Date(now).toISOString() | 265 | + | connectionId: connectionId.slice(0, 8) |
| 232 | - | }); | 266 | + | }); |
| 233 | 267 | ||||
| 234 | - | // Invalidate buddy list cache when user status changes | 268 | + | buddyListCache.invalidate(); |
| 235 | - | buddyListCache.invalidate(); | |||
| 236 | 269 | ||||
| 237 | - | sseEmitter.emit('sse', { | 270 | + | sseEmitter.emit('userStatusUpdate', { |
| 238 | - | type: 'userStatusUpdate', | 271 | + | userId, |
| 239 | - | data: { | 272 | + | status: 'online', |
| 240 | - | userId, | 273 | + | lastSeen: now |
| 241 | - | status: 'online', | 274 | + | }); |
| 242 | - | lastSeen: now | 275 | + | } catch (error) { |
| 276 | + | log.error('Error updating user status', { error }); | |||
| 243 | } | 277 | } | ||
| 244 | - | }); | 278 | + | } |
| 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 | 279 | ||||
| 255 | - | const stream = new ReadableStream({ | 280 | + | // Send initial connection event |
| 256 | - | start(controller) { | 281 | + | const connectEvent = formatSSEEvent( |
| 257 | - | const sendEvent = (event: { type: string; data: unknown }) => { | 282 | + | { type: 'userStatusUpdate', data: { userId, status: 'connected', lastSeen: Date.now() } }, |
| 283 | + | crypto.randomUUID() | |||
| 284 | + | ); | |||
| 285 | + | controller.enqueue(encoder.encode(connectEvent)); | |||
| 286 | + | ||||
| 287 | + | // Send catch-up messages if reconnecting with Last-Event-ID | |||
| 288 | + | if (lastEventId) { | |||
| 289 | + | const missedMessages = messageQueue.getMessagesSince(roomId, lastEventId); | |||
| 290 | + | if (missedMessages.length > 0) { | |||
| 291 | + | log.info('Sending catch-up messages', { | |||
| 292 | + | userId: userId.slice(0, 8), | |||
| 293 | + | count: missedMessages.length | |||
| 294 | + | }); | |||
| 295 | + | for (const msg of missedMessages) { | |||
| 296 | + | // Cast to SSEEvent - the data was already validated when queued | |||
| 297 | + | const event = { type: msg.event, data: msg.data } as SSEEvent; | |||
| 298 | + | const payload = formatSSEEvent(event, msg.id); | |||
| 299 | + | controller.enqueue(encoder.encode(payload)); | |||
| 300 | + | } | |||
| 301 | + | } | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // Listen for SSE events | |||
| 305 | + | const onSSE = (event: SSEEvent) => { | |||
| 258 | try { | 306 | try { | ||
| 259 | - | const payload = `event: ${event.type}\n` + `data: ${JSON.stringify(event.data)}\n\n`; | 307 | + | const eventId = crypto.randomUUID(); |
| 308 | + | const payload = formatSSEEvent(event, eventId); | |||
| 260 | controller.enqueue(encoder.encode(payload)); | 309 | controller.enqueue(encoder.encode(payload)); | ||
| 261 | - | if (!['userStatusUpdate', 'chatMessage'].includes(event.type)) { | 310 | + | |
| 262 | - | log.info('Event sent', { type: event.type }); | 311 | + | // Queue message for catch-up (if not heartbeat) |
| 312 | + | if (event.type !== 'heartbeat') { | |||
| 313 | + | messageQueue.enqueue(roomId, event.type, event.data); | |||
| 263 | } | 314 | } | ||
| 264 | } catch (error) { | 315 | } catch (error) { | ||
| 265 | log.error('Error sending event', { error }); | 316 | log.error('Error sending event', { error }); | ||
| 266 | } | 317 | } | ||
| 267 | }; | 318 | }; | ||
| 268 | 319 | ||||
| 269 | - | onSSE = (event: { type: string; data: unknown }) => { | 320 | + | cleanupListener = sseEmitter.addListener('sse', onSSE); |
| 270 | - | sendEvent(event); | |||
| 271 | - | }; | |||
| 272 | 321 | ||||
| 273 | - | sseEmitter.addListener('sse', onSSE); | 322 | + | // Handle abort signal |
| 274 | request.signal.addEventListener('abort', () => { | 323 | request.signal.addEventListener('abort', () => { | ||
| 275 | - | if (onSSE) { | 324 | + | if (cleanupListener) { |
| 276 | - | sseEmitter.removeListener('sse', onSSE); | 325 | + | cleanupListener(); |
| 277 | log.info('Removed listener due to abort signal'); | 326 | log.info('Removed listener due to abort signal'); | ||
| 278 | } | 327 | } | ||
| 279 | }); | 328 | }); | ||
| 280 | - | log.info('Added new listener'); | |||
| 281 | 329 | ||||
| 282 | - | controller.enqueue(encoder.encode('data: Connected\n\n')); | 330 | + | log.info('SSE connection established', { |
| 331 | + | connectionId: connectionId.slice(0, 8), | |||
| 332 | + | userId: userId.slice(0, 8) | |||
| 333 | + | }); | |||
| 283 | 334 | ||||
| 335 | + | // Keep-alive pings (in addition to heartbeats from manager) | |||
| 284 | keepAliveInterval = setInterval(() => { | 336 | keepAliveInterval = setInterval(() => { | ||
| 285 | - | controller.enqueue(encoder.encode(':\n\n')); | 337 | + | try { |
| 286 | - | }, 20000); | 338 | + | controller.enqueue(encoder.encode(':\n\n')); |
| 339 | + | } catch { | |||
| 340 | + | // Connection might be closed | |||
| 341 | + | } | |||
| 342 | + | }, SSE_CONFIG.HEARTBEAT_INTERVAL); | |||
| 287 | }, | 343 | }, | ||
| 344 | + | ||||
| 288 | async cancel(reason) { | 345 | async cancel(reason) { | ||
| 289 | if (keepAliveInterval) clearInterval(keepAliveInterval); | 346 | if (keepAliveInterval) clearInterval(keepAliveInterval); | ||
| 290 | - | if (onSSE) sseEmitter.removeListener('sse', onSSE); | 347 | + | if (cleanupListener) cleanupListener(); |
| 291 | - | const maskedUserId = `${userId.slice(0, 4)}...${userId.slice(-4)}`; | |||
| 292 | - | log.info('Connection closed', { userId: maskedUserId, reason }); | |||
| 293 | 348 | ||||
| 294 | - | // Remove from connection tracker | 349 | + | const maskedUserId = userId.slice(0, 8); |
| 295 | - | const wasTracked = sseConnectionTracker.removeConnection(userId); | 350 | + | log.info('Connection closed', { userId: maskedUserId, connectionId: connectionId?.slice(0, 8), reason }); |
| 296 | 351 | ||||
| 297 | - | // Only update database if this was the last connection | 352 | + | // Remove connection from manager |
| 298 | - | if (wasTracked && !sseConnectionTracker.hasConnection(userId)) { | 353 | + | const { isLastConnection } = sseManager.removeConnection(connectionId); |
| 354 | + | ||||
| 355 | + | // Update database if last connection for this user | |||
| 356 | + | if (isLastConnection) { | |||
| 299 | const now = Date.now(); | 357 | const now = Date.now(); | ||
| 300 | - | await db | 358 | + | try { |
| 301 | - | .update(users) | 359 | + | await db |
| 302 | - | .set({ status: 'offline', lastSeen: now }) | 360 | + | .update(users) |
| 303 | - | .where(eq(users.id, userId)); | 361 | + | .set({ status: 'offline', lastSeen: now }) |
| 362 | + | .where(eq(users.id, userId)); | |||
| 304 | 363 | ||||
| 305 | - | // Invalidate buddy list cache when user goes offline | 364 | + | buddyListCache.invalidate(); |
| 306 | - | buddyListCache.invalidate(); | |||
| 307 | 365 | ||||
| 308 | - | sseEmitter.emit('sse', { | 366 | + | sseEmitter.emit('userStatusUpdate', { |
| 309 | - | type: 'userStatusUpdate', | |||
| 310 | - | data: { | |||
| 311 | userId, | 367 | userId, | ||
| 312 | status: 'offline', | 368 | status: 'offline', | ||
| 313 | lastSeen: now | 369 | lastSeen: now | ||
| 314 | - | } | 370 | + | }); |
| 315 | - | }); | 371 | + | } catch (error) { |
| 372 | + | log.error('Error updating user status on disconnect', { error }); | |||
| 373 | + | } | |||
| 316 | } | 374 | } | ||
| 317 | } | 375 | } | ||
| 318 | }); | 376 | }); | ||
| @@ -321,7 +379,8 @@ export const GET: RequestHandler = async ({ request, locals }) => { | |||||
| 321 | headers: { | 379 | headers: { | ||
| 322 | 'Content-Type': 'text/event-stream', | 380 | 'Content-Type': 'text/event-stream', | ||
| 323 | 'Cache-Control': 'no-cache', | 381 | 'Cache-Control': 'no-cache', | ||
| 324 | - | Connection: 'keep-alive' | 382 | + | 'Connection': 'keep-alive', |
| 383 | + | 'X-Accel-Buffering': 'no' // Disable nginx buffering | |||
| 325 | } | 384 | } | ||
| 326 | }); | 385 | }); | ||
| 327 | }; | 386 | }; | ||
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 | + | }; | |||