feat: Enhance chat state management and SSE connection handling
- Improve chat state initialization with more robust user and message handling - Add support for infinite scroll and message loading in chat room - Optimize SSE connection with better error handling and reconnection logic - Implement memoization for message enrichment to improve performance - Add development-friendly rate limiting configuration - Enhance user data fetching and caching mechanisms
- commit
- 16eb1cf19ad234f460b3a9557aa87c455de651b9
- author
- Kit · · 2025-02-14 16:16 UTC
- parent
- dabbe8a
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
8 files · +414 -167
M src/lib/api/rate-limiter.ts +27 -5
| @@ -1,20 +1,37 @@ | |||
| 1 | 1 | import type { RequestEvent } from '@sveltejs/kit'; | |
| 2 | 2 | ||
| 3 | + | const isDev = import.meta.env.DEV; | |
| 4 | + | ||
| 3 | 5 | // Rate limit configuration by endpoint type | |
| 4 | 6 | const RATE_LIMITS = { | |
| 5 | 7 | // Authentication endpoints (login, register) | |
| 6 | - | auth: { points: 20, durationMs: 5 * 60 * 1000 }, // 20 requests per 5 minutes | |
| 8 | + | auth: { | |
| 9 | + | points: isDev ? 1000 : 20, | |
| 10 | + | durationMs: 5 * 60 * 1000 | |
| 11 | + | }, | |
| 7 | 12 | ||
| 8 | 13 | // Public endpoints (public chat, rooms) | |
| 9 | - | public: { points: 30, durationMs: 60 * 1000 }, // 30 requests per minute | |
| 14 | + | public: { | |
| 15 | + | points: isDev ? 1000 : 30, | |
| 16 | + | durationMs: 60 * 1000 | |
| 17 | + | }, | |
| 10 | 18 | ||
| 11 | 19 | // Protected endpoints (authenticated API calls) | |
| 12 | - | protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute | |
| 20 | + | protected: { | |
| 21 | + | points: isDev ? 1000 : 100, | |
| 22 | + | durationMs: 60 * 1000 | |
| 23 | + | }, | |
| 13 | 24 | ||
| 14 | 25 | // SSE endpoints - separate limits for authenticated and unauthenticated users | |
| 15 | 26 | sse: { | |
| 16 | - | authenticated: { points: 100, durationMs: 60 * 1000 }, // 10 connections per minute for logged in users | |
| 17 | - | unauthenticated: { points: 10, durationMs: 60 * 1000 } // 2 connections per minute for public users | |
| 27 | + | authenticated: { | |
| 28 | + | points: isDev ? 1000 : 100, | |
| 29 | + | durationMs: 60 * 1000 | |
| 30 | + | }, | |
| 31 | + | unauthenticated: { | |
| 32 | + | points: isDev ? 1000 : 10, | |
| 33 | + | durationMs: 60 * 1000 | |
| 34 | + | } | |
| 18 | 35 | } | |
| 19 | 36 | } as const; | |
| 20 | 37 | ||
| @@ -49,6 +66,11 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE | |||
| 49 | 66 | } | |
| 50 | 67 | ||
| 51 | 68 | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } { | |
| 69 | + | // Skip rate limiting completely in development for localhost | |
| 70 | + | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | |
| 71 | + | return { limited: false }; | |
| 72 | + | } | |
| 73 | + | ||
| 52 | 74 | const now = Date.now(); | |
| 53 | 75 | ||
| 54 | 76 | // Handle SSE endpoints differently based on authentication status | |
| @@ -1,20 +1,37 @@ | |||||
| 1 | import type { RequestEvent } from '@sveltejs/kit'; | 1 | import type { RequestEvent } from '@sveltejs/kit'; | ||
| 2 | 2 | ||||
| 3 | + | const isDev = import.meta.env.DEV; | |||
| 4 | + | ||||
| 3 | // Rate limit configuration by endpoint type | 5 | // Rate limit configuration by endpoint type | ||
| 4 | const RATE_LIMITS = { | 6 | const RATE_LIMITS = { | ||
| 5 | // Authentication endpoints (login, register) | 7 | // Authentication endpoints (login, register) | ||
| 6 | - | auth: { points: 20, durationMs: 5 * 60 * 1000 }, // 20 requests per 5 minutes | 8 | + | auth: { |
| 9 | + | points: isDev ? 1000 : 20, | |||
| 10 | + | durationMs: 5 * 60 * 1000 | |||
| 11 | + | }, | |||
| 7 | 12 | ||||
| 8 | // Public endpoints (public chat, rooms) | 13 | // Public endpoints (public chat, rooms) | ||
| 9 | - | public: { points: 30, durationMs: 60 * 1000 }, // 30 requests per minute | 14 | + | public: { |
| 15 | + | points: isDev ? 1000 : 30, | |||
| 16 | + | durationMs: 60 * 1000 | |||
| 17 | + | }, | |||
| 10 | 18 | ||||
| 11 | // Protected endpoints (authenticated API calls) | 19 | // Protected endpoints (authenticated API calls) | ||
| 12 | - | protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute | 20 | + | protected: { |
| 21 | + | points: isDev ? 1000 : 100, | |||
| 22 | + | durationMs: 60 * 1000 | |||
| 23 | + | }, | |||
| 13 | 24 | ||||
| 14 | // SSE endpoints - separate limits for authenticated and unauthenticated users | 25 | // SSE endpoints - separate limits for authenticated and unauthenticated users | ||
| 15 | sse: { | 26 | sse: { | ||
| 16 | - | authenticated: { points: 100, durationMs: 60 * 1000 }, // 10 connections per minute for logged in users | 27 | + | authenticated: { |
| 17 | - | unauthenticated: { points: 10, durationMs: 60 * 1000 } // 2 connections per minute for public users | 28 | + | points: isDev ? 1000 : 100, |
| 29 | + | durationMs: 60 * 1000 | |||
| 30 | + | }, | |||
| 31 | + | unauthenticated: { | |||
| 32 | + | points: isDev ? 1000 : 10, | |||
| 33 | + | durationMs: 60 * 1000 | |||
| 34 | + | } | |||
| 18 | } | 35 | } | ||
| 19 | } as const; | 36 | } as const; | ||
| 20 | 37 | ||||
| @@ -49,6 +66,11 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE | |||||
| 49 | } | 66 | } | ||
| 50 | 67 | ||||
| 51 | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } { | 68 | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } { | ||
| 69 | + | // Skip rate limiting completely in development for localhost | |||
| 70 | + | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | |||
| 71 | + | return { limited: false }; | |||
| 72 | + | } | |||
| 73 | + | ||||
| 52 | const now = Date.now(); | 74 | const now = Date.now(); | ||
| 53 | 75 | ||||
| 54 | // Handle SSE endpoints differently based on authentication status | 76 | // Handle SSE endpoints differently based on authentication status | ||
M src/lib/components/aim-login.svelte +3 -1
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | |
| 5 | 5 | import type { User } from '$lib/types/chat'; | |
| 6 | 6 | import { invalidateAll } from '$app/navigation'; | |
| 7 | + | import { chatState } from '$lib/states/chat.svelte'; | |
| 7 | 8 | ||
| 8 | 9 | let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props(); | |
| 9 | 10 | ||
| @@ -57,6 +58,7 @@ | |||
| 57 | 58 | } | |
| 58 | 59 | ||
| 59 | 60 | console.debug("Login succeeded:", data); | |
| 61 | + | await chatState.setCurrentUser(data.user); | |
| 60 | 62 | loginStatus = 'success'; | |
| 61 | 63 | ||
| 62 | 64 | // Call the callback prop if provided | |
| @@ -132,7 +134,7 @@ | |||
| 132 | 134 | return; | |
| 133 | 135 | } | |
| 134 | 136 | console.debug("Auto login after registration succeeded:", data); | |
| 135 | - | ||
| 137 | + | await chatState.setCurrentUser(data.user); | |
| 136 | 138 | onLoginSuccess?.(data.user); | |
| 137 | 139 | setTimeout(() => { | |
| 138 | 140 | console.debug("Auto closing AIM Login component after auto login on registration"); | |
| @@ -4,6 +4,7 @@ | |||||
| 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | 4 | import type { RegisterResponse, RegisterResponseError } from '$lib/types/payloads'; | ||
| 5 | import type { User } from '$lib/types/chat'; | 5 | import type { User } from '$lib/types/chat'; | ||
| 6 | import { invalidateAll } from '$app/navigation'; | 6 | import { invalidateAll } from '$app/navigation'; | ||
| 7 | + | import { chatState } from '$lib/states/chat.svelte'; | |||
| 7 | 8 | ||||
| 8 | let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props(); | 9 | let { onLoginSuccess, showAuth = $bindable() }: { onLoginSuccess?: (user: User) => void, showAuth:Boolean } = $props(); | ||
| 9 | 10 | ||||
| @@ -57,6 +58,7 @@ | |||||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| 59 | console.debug("Login succeeded:", data); | 60 | console.debug("Login succeeded:", data); | ||
| 61 | + | await chatState.setCurrentUser(data.user); | |||
| 60 | loginStatus = 'success'; | 62 | loginStatus = 'success'; | ||
| 61 | 63 | ||||
| 62 | // Call the callback prop if provided | 64 | // Call the callback prop if provided | ||
| @@ -132,7 +134,7 @@ | |||||
| 132 | return; | 134 | return; | ||
| 133 | } | 135 | } | ||
| 134 | console.debug("Auto login after registration succeeded:", data); | 136 | console.debug("Auto login after registration succeeded:", data); | ||
| 135 | - | 137 | + | await chatState.setCurrentUser(data.user); | |
| 136 | onLoginSuccess?.(data.user); | 138 | onLoginSuccess?.(data.user); | ||
| 137 | setTimeout(() => { | 139 | setTimeout(() => { | ||
| 138 | console.debug("Auto closing AIM Login component after auto login on registration"); | 140 | console.debug("Auto closing AIM Login component after auto login on registration"); | ||
M src/lib/components/chat-room.svelte +80 -4
| @@ -62,6 +62,10 @@ function handleClose() { | |||
| 62 | 62 | let messages = $state<EnrichedMessage[]>([]); | |
| 63 | 63 | let onlineUsers = $state<User[]>([]); | |
| 64 | 64 | let currentUser = $state<User | null>(null); | |
| 65 | + | let isLoadingMore = $state(false); | |
| 66 | + | let hasMoreMessages = $state(true); | |
| 67 | + | let oldestMessageTimestamp = $state<number | null>(null); | |
| 68 | + | let currentRoomId = $state(DEFAULT_CHAT_ROOM_ID); | |
| 65 | 69 | ||
| 66 | 70 | // Compute visible messages based on login status | |
| 67 | 71 | let visibleMessages = $derived((() => { | |
| @@ -82,6 +86,7 @@ $effect(() => { | |||
| 82 | 86 | const user = chatState.getCurrentUser(); | |
| 83 | 87 | if (user?.id !== currentUser?.id) { | |
| 84 | 88 | currentUser = user; | |
| 89 | + | hasMoreMessages = true; // Reset when user changes | |
| 85 | 90 | } | |
| 86 | 91 | ||
| 87 | 92 | // Check for SSE errors | |
| @@ -89,11 +94,16 @@ $effect(() => { | |||
| 89 | 94 | sseError = error; | |
| 90 | 95 | sseRetryAfter = retryAfter; | |
| 91 | 96 | ||
| 92 | - | // Only update messages if we have new ones | |
| 97 | + | // Update messages if there's any difference | |
| 93 | 98 | const stateMessages = chatState.getMessages(); | |
| 94 | - | if (stateMessages.length !== messages.length) { | |
| 99 | + | if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) { | |
| 95 | 100 | messages = stateMessages; | |
| 96 | 101 | ||
| 102 | + | // Update oldest message timestamp | |
| 103 | + | if (messages.length > 0) { | |
| 104 | + | oldestMessageTimestamp = Math.min(...messages.map(m => m.timestamp)); | |
| 105 | + | } | |
| 106 | + | ||
| 97 | 107 | // Scroll to bottom when new messages arrive | |
| 98 | 108 | const chatArea = document.querySelector('.chat-area'); | |
| 99 | 109 | if (chatArea) { | |
| @@ -128,15 +138,17 @@ $effect(() => { | |||
| 128 | 138 | ]) | |
| 129 | 139 | .then(([roomData, messagesData]) => { | |
| 130 | 140 | if (roomData.success) { | |
| 131 | - | // First update the chat state with the buddy list | |
| 132 | - | chatState.updateUserCache(roomData.buddyList); | |
| 141 | + | // Update both the chat state and local state with the buddy list | |
| 142 | + | chatState.updateOnlineUsers(roomData.buddyList); | |
| 133 | 143 | onlineUsers = roomData.buddyList; | |
| 144 | + | console.debug('Updated public buddy list:', roomData.buddyList); | |
| 134 | 145 | } | |
| 135 | 146 | ||
| 136 | 147 | // Then update messages after buddy list is cached | |
| 137 | 148 | if (messagesData.success && messagesData.messages) { | |
| 138 | 149 | chatState.updateMessages(messagesData.messages); | |
| 139 | 150 | messages = chatState.enrichMessages(messagesData.messages); | |
| 151 | + | console.debug('Updated public messages:', messages); | |
| 140 | 152 | } | |
| 141 | 153 | }) | |
| 142 | 154 | .catch(error => { | |
| @@ -277,6 +289,54 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 277 | 289 | windowX = event.detail.x; | |
| 278 | 290 | windowY = event.detail.y; | |
| 279 | 291 | } | |
| 292 | + | ||
| 293 | + | async function handleScroll(event: Event) { | |
| 294 | + | const chatArea = event.target as HTMLElement; | |
| 295 | + | const { scrollTop } = chatArea; | |
| 296 | + | ||
| 297 | + | // Check if we've scrolled near the top (within 100px) and not already loading | |
| 298 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) { | |
| 299 | + | isLoadingMore = true; | |
| 300 | + | console.debug('Loading more messages...', { oldestMessageTimestamp }); | |
| 301 | + | ||
| 302 | + | try { | |
| 303 | + | const response = await fetch(`/api/chat/messages?${new URLSearchParams({ | |
| 304 | + | before: oldestMessageTimestamp?.toString() || '', | |
| 305 | + | roomId: currentRoomId, | |
| 306 | + | ...(currentUser ? {} : { public: 'true' }) | |
| 307 | + | })}`); | |
| 308 | + | ||
| 309 | + | if (response.ok) { | |
| 310 | + | const data = await response.json(); | |
| 311 | + | if (data.success) { | |
| 312 | + | if (data.messages.length === 0) { | |
| 313 | + | hasMoreMessages = false; | |
| 314 | + | } else { | |
| 315 | + | // Preserve scroll position | |
| 316 | + | const oldHeight = chatArea.scrollHeight; | |
| 317 | + | ||
| 318 | + | // Update messages | |
| 319 | + | chatState.prependMessages(data.messages); | |
| 320 | + | ||
| 321 | + | // After the DOM updates, adjust scroll position | |
| 322 | + | setTimeout(() => { | |
| 323 | + | const newHeight = chatArea.scrollHeight; | |
| 324 | + | chatArea.scrollTop = newHeight - oldHeight; | |
| 325 | + | }, 0); | |
| 326 | + | ||
| 327 | + | // Update oldest timestamp | |
| 328 | + | const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp)); | |
| 329 | + | oldestMessageTimestamp = newOldest; | |
| 330 | + | } | |
| 331 | + | } | |
| 332 | + | } | |
| 333 | + | } catch (error) { | |
| 334 | + | console.error('Error loading more messages:', error); | |
| 335 | + | } finally { | |
| 336 | + | isLoadingMore = false; | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | } | |
| 280 | 340 | </script> | |
| 281 | 341 | ||
| 282 | 342 | {#if showChatRoom} | |
| @@ -351,7 +411,13 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 351 | 411 | <div | |
| 352 | 412 | class="sunken-panel chat-area" | |
| 353 | 413 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | |
| 414 | + | onscroll={(e) => handleScroll(e)} | |
| 354 | 415 | > | |
| 416 | + | {#if isLoadingMore} | |
| 417 | + | <div class="loading-messages"> | |
| 418 | + | Loading more messages... | |
| 419 | + | </div> | |
| 420 | + | {/if} | |
| 355 | 421 | {#each visibleMessages as message (message.id)} | |
| 356 | 422 | <div class="message {message.type} text"> | |
| 357 | 423 | {#if message.type === 'emote'} | |
| @@ -691,4 +757,14 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 691 | 757 | background: #e65100; | |
| 692 | 758 | transition: width 0.1s linear; | |
| 693 | 759 | } | |
| 760 | + | ||
| 761 | + | .loading-messages { | |
| 762 | + | text-align: center; | |
| 763 | + | padding: 1rem; | |
| 764 | + | color: #666; | |
| 765 | + | font-style: italic; | |
| 766 | + | background: rgba(0, 0, 0, 0.05); | |
| 767 | + | margin-bottom: 1rem; | |
| 768 | + | border-radius: 4px; | |
| 769 | + | } | |
| 694 | 770 | </style>⏎̸ | |
| @@ -62,6 +62,10 @@ function handleClose() { | |||||
| 62 | let messages = $state<EnrichedMessage[]>([]); | 62 | let messages = $state<EnrichedMessage[]>([]); | ||
| 63 | let onlineUsers = $state<User[]>([]); | 63 | let onlineUsers = $state<User[]>([]); | ||
| 64 | let currentUser = $state<User | null>(null); | 64 | let currentUser = $state<User | null>(null); | ||
| 65 | + | let isLoadingMore = $state(false); | |||
| 66 | + | let hasMoreMessages = $state(true); | |||
| 67 | + | let oldestMessageTimestamp = $state<number | null>(null); | |||
| 68 | + | let currentRoomId = $state(DEFAULT_CHAT_ROOM_ID); | |||
| 65 | 69 | ||||
| 66 | // Compute visible messages based on login status | 70 | // Compute visible messages based on login status | ||
| 67 | let visibleMessages = $derived((() => { | 71 | let visibleMessages = $derived((() => { | ||
| @@ -82,6 +86,7 @@ $effect(() => { | |||||
| 82 | const user = chatState.getCurrentUser(); | 86 | const user = chatState.getCurrentUser(); | ||
| 83 | if (user?.id !== currentUser?.id) { | 87 | if (user?.id !== currentUser?.id) { | ||
| 84 | currentUser = user; | 88 | currentUser = user; | ||
| 89 | + | hasMoreMessages = true; // Reset when user changes | |||
| 85 | } | 90 | } | ||
| 86 | 91 | ||||
| 87 | // Check for SSE errors | 92 | // Check for SSE errors | ||
| @@ -89,11 +94,16 @@ $effect(() => { | |||||
| 89 | sseError = error; | 94 | sseError = error; | ||
| 90 | sseRetryAfter = retryAfter; | 95 | sseRetryAfter = retryAfter; | ||
| 91 | 96 | ||||
| 92 | - | // Only update messages if we have new ones | 97 | + | // Update messages if there's any difference |
| 93 | const stateMessages = chatState.getMessages(); | 98 | const stateMessages = chatState.getMessages(); | ||
| 94 | - | if (stateMessages.length !== messages.length) { | 99 | + | if (JSON.stringify(stateMessages) !== JSON.stringify(messages)) { |
| 95 | messages = stateMessages; | 100 | messages = stateMessages; | ||
| 96 | 101 | ||||
| 102 | + | // Update oldest message timestamp | |||
| 103 | + | if (messages.length > 0) { | |||
| 104 | + | oldestMessageTimestamp = Math.min(...messages.map(m => m.timestamp)); | |||
| 105 | + | } | |||
| 106 | + | ||||
| 97 | // Scroll to bottom when new messages arrive | 107 | // Scroll to bottom when new messages arrive | ||
| 98 | const chatArea = document.querySelector('.chat-area'); | 108 | const chatArea = document.querySelector('.chat-area'); | ||
| 99 | if (chatArea) { | 109 | if (chatArea) { | ||
| @@ -128,15 +138,17 @@ $effect(() => { | |||||
| 128 | ]) | 138 | ]) | ||
| 129 | .then(([roomData, messagesData]) => { | 139 | .then(([roomData, messagesData]) => { | ||
| 130 | if (roomData.success) { | 140 | if (roomData.success) { | ||
| 131 | - | // First update the chat state with the buddy list | 141 | + | // Update both the chat state and local state with the buddy list |
| 132 | - | chatState.updateUserCache(roomData.buddyList); | 142 | + | chatState.updateOnlineUsers(roomData.buddyList); |
| 133 | onlineUsers = roomData.buddyList; | 143 | onlineUsers = roomData.buddyList; | ||
| 144 | + | console.debug('Updated public buddy list:', roomData.buddyList); | |||
| 134 | } | 145 | } | ||
| 135 | 146 | ||||
| 136 | // Then update messages after buddy list is cached | 147 | // Then update messages after buddy list is cached | ||
| 137 | if (messagesData.success && messagesData.messages) { | 148 | if (messagesData.success && messagesData.messages) { | ||
| 138 | chatState.updateMessages(messagesData.messages); | 149 | chatState.updateMessages(messagesData.messages); | ||
| 139 | messages = chatState.enrichMessages(messagesData.messages); | 150 | messages = chatState.enrichMessages(messagesData.messages); | ||
| 151 | + | console.debug('Updated public messages:', messages); | |||
| 140 | } | 152 | } | ||
| 141 | }) | 153 | }) | ||
| 142 | .catch(error => { | 154 | .catch(error => { | ||
| @@ -277,6 +289,54 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 277 | windowX = event.detail.x; | 289 | windowX = event.detail.x; | ||
| 278 | windowY = event.detail.y; | 290 | windowY = event.detail.y; | ||
| 279 | } | 291 | } | ||
| 292 | + | ||||
| 293 | + | async function handleScroll(event: Event) { | |||
| 294 | + | const chatArea = event.target as HTMLElement; | |||
| 295 | + | const { scrollTop } = chatArea; | |||
| 296 | + | ||||
| 297 | + | // Check if we've scrolled near the top (within 100px) and not already loading | |||
| 298 | + | if (scrollTop < 100 && !isLoadingMore && hasMoreMessages) { | |||
| 299 | + | isLoadingMore = true; | |||
| 300 | + | console.debug('Loading more messages...', { oldestMessageTimestamp }); | |||
| 301 | + | ||||
| 302 | + | try { | |||
| 303 | + | const response = await fetch(`/api/chat/messages?${new URLSearchParams({ | |||
| 304 | + | before: oldestMessageTimestamp?.toString() || '', | |||
| 305 | + | roomId: currentRoomId, | |||
| 306 | + | ...(currentUser ? {} : { public: 'true' }) | |||
| 307 | + | })}`); | |||
| 308 | + | ||||
| 309 | + | if (response.ok) { | |||
| 310 | + | const data = await response.json(); | |||
| 311 | + | if (data.success) { | |||
| 312 | + | if (data.messages.length === 0) { | |||
| 313 | + | hasMoreMessages = false; | |||
| 314 | + | } else { | |||
| 315 | + | // Preserve scroll position | |||
| 316 | + | const oldHeight = chatArea.scrollHeight; | |||
| 317 | + | ||||
| 318 | + | // Update messages | |||
| 319 | + | chatState.prependMessages(data.messages); | |||
| 320 | + | ||||
| 321 | + | // After the DOM updates, adjust scroll position | |||
| 322 | + | setTimeout(() => { | |||
| 323 | + | const newHeight = chatArea.scrollHeight; | |||
| 324 | + | chatArea.scrollTop = newHeight - oldHeight; | |||
| 325 | + | }, 0); | |||
| 326 | + | ||||
| 327 | + | // Update oldest timestamp | |||
| 328 | + | const newOldest = Math.min(...data.messages.map((m: Message) => m.timestamp)); | |||
| 329 | + | oldestMessageTimestamp = newOldest; | |||
| 330 | + | } | |||
| 331 | + | } | |||
| 332 | + | } | |||
| 333 | + | } catch (error) { | |||
| 334 | + | console.error('Error loading more messages:', error); | |||
| 335 | + | } finally { | |||
| 336 | + | isLoadingMore = false; | |||
| 337 | + | } | |||
| 338 | + | } | |||
| 339 | + | } | |||
| 280 | </script> | 340 | </script> | ||
| 281 | 341 | ||||
| 282 | {#if showChatRoom} | 342 | {#if showChatRoom} | ||
| @@ -351,7 +411,13 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 351 | <div | 411 | <div | ||
| 352 | class="sunken-panel chat-area" | 412 | class="sunken-panel chat-area" | ||
| 353 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | 413 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | ||
| 414 | + | onscroll={(e) => handleScroll(e)} | |||
| 354 | > | 415 | > | ||
| 416 | + | {#if isLoadingMore} | |||
| 417 | + | <div class="loading-messages"> | |||
| 418 | + | Loading more messages... | |||
| 419 | + | </div> | |||
| 420 | + | {/if} | |||
| 355 | {#each visibleMessages as message (message.id)} | 421 | {#each visibleMessages as message (message.id)} | ||
| 356 | <div class="message {message.type} text"> | 422 | <div class="message {message.type} text"> | ||
| 357 | {#if message.type === 'emote'} | 423 | {#if message.type === 'emote'} | ||
| @@ -691,4 +757,14 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 691 | background: #e65100; | 757 | background: #e65100; | ||
| 692 | transition: width 0.1s linear; | 758 | transition: width 0.1s linear; | ||
| 693 | } | 759 | } | ||
| 760 | + | ||||
| 761 | + | .loading-messages { | |||
| 762 | + | text-align: center; | |||
| 763 | + | padding: 1rem; | |||
| 764 | + | color: #666; | |||
| 765 | + | font-style: italic; | |||
| 766 | + | background: rgba(0, 0, 0, 0.05); | |||
| 767 | + | margin-bottom: 1rem; | |||
| 768 | + | border-radius: 4px; | |||
| 769 | + | } | |||
| 694 | </style>⏎̸ | 770 | </style>⏎̸ | ||
M src/lib/components/desktop-icons.svelte +0 -2
| @@ -9,12 +9,10 @@ | |||
| 9 | 9 | } | |
| 10 | 10 | ||
| 11 | 11 | function handleChatRoomClick() { | |
| 12 | - | console.log('handleChatRoomClick'); | |
| 13 | 12 | showChatRoom = true | |
| 14 | 13 | } | |
| 15 | 14 | ||
| 16 | 15 | function handleAuthClick() { | |
| 17 | - | console.log('handleAuthClick'); | |
| 18 | 16 | showAuth = true; | |
| 19 | 17 | } | |
| 20 | 18 | </script> | |
| @@ -9,12 +9,10 @@ | |||||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| 11 | function handleChatRoomClick() { | 11 | function handleChatRoomClick() { | ||
| 12 | - | console.log('handleChatRoomClick'); | |||
| 13 | showChatRoom = true | 12 | showChatRoom = true | ||
| 14 | } | 13 | } | ||
| 15 | 14 | ||||
| 16 | function handleAuthClick() { | 15 | function handleAuthClick() { | ||
| 17 | - | console.log('handleAuthClick'); | |||
| 18 | showAuth = true; | 16 | showAuth = true; | ||
| 19 | } | 17 | } | ||
| 20 | </script> | 18 | </script> | ||
M src/lib/states/chat.svelte.ts +231 -137
| @@ -23,8 +23,18 @@ class ChatState { | |||
| 23 | 23 | private sseRetryAfter = $state<number | null>(null); | |
| 24 | 24 | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 25 | 25 | private isReconnecting = $state(false); | |
| 26 | + | private isSettingUser = $state(false); | |
| 27 | + | private isConnecting = $state(false); | |
| 28 | + | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | |
| 29 | + | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | |
| 30 | + | private _lastEnrichmentKey: string = ''; | |
| 26 | 31 | ||
| 27 | 32 | public async reinitialize() { | |
| 33 | + | if (this.isInitializing) { | |
| 34 | + | console.debug('Reinitialize already in progress, skipping.'); | |
| 35 | + | return; | |
| 36 | + | } | |
| 37 | + | this.isInitializing = true; | |
| 28 | 38 | console.debug('Reinitializing chat state...'); | |
| 29 | 39 | if (this.eventSource) { | |
| 30 | 40 | this.eventSource.close(); | |
| @@ -34,6 +44,8 @@ class ChatState { | |||
| 34 | 44 | this.reconnectDelay = 1000; | |
| 35 | 45 | this.messages = []; | |
| 36 | 46 | if (this.currentUser) { | |
| 47 | + | console.debug('Waiting for session cookie update before setting up SSE...'); | |
| 48 | + | await new Promise(resolve => setTimeout(resolve, 1000)); | |
| 37 | 49 | // First set up SSE to receive status updates | |
| 38 | 50 | this.setupSSE(); | |
| 39 | 51 | // Then initialize messages and room users | |
| @@ -42,6 +54,14 @@ class ChatState { | |||
| 42 | 54 | this.initializeRoomUsers() | |
| 43 | 55 | ]); | |
| 44 | 56 | } | |
| 57 | + | this.isInitializing = false; | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | getSSEError() { | |
| 61 | + | return { | |
| 62 | + | error: this.sseError, | |
| 63 | + | retryAfter: this.sseRetryAfter | |
| 64 | + | }; | |
| 45 | 65 | } | |
| 46 | 66 | ||
| 47 | 67 | // User methods | |
| @@ -51,34 +71,49 @@ class ChatState { | |||
| 51 | 71 | ||
| 52 | 72 | async setCurrentUser(user: User | null) { | |
| 53 | 73 | console.debug('setCurrentUser called with', user); | |
| 54 | - | this.currentUser = user; | |
| 55 | - | ||
| 56 | - | if (user) { | |
| 57 | - | // Add the current user to the cache immediately | |
| 58 | - | this.userCache[user.id] = user; | |
| 59 | - | // First set up SSE to receive status updates | |
| 60 | - | this.setupSSE(); | |
| 61 | - | // Then initialize messages and room users | |
| 62 | - | await Promise.all([ | |
| 63 | - | this.initializeMessages(), | |
| 64 | - | this.initializeRoomUsers() | |
| 65 | - | ]); | |
| 66 | - | } else { | |
| 67 | - | // If no global session, clear SSE and state | |
| 68 | - | if (this.eventSource) { | |
| 69 | - | this.eventSource.close(); | |
| 70 | - | this.eventSource = null; | |
| 74 | + | ||
| 75 | + | // Prevent concurrent setCurrentUser calls | |
| 76 | + | if (this.isSettingUser) { | |
| 77 | + | console.debug('Already setting user, skipping'); | |
| 78 | + | return; | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | this.isSettingUser = true; | |
| 82 | + | ||
| 83 | + | try { | |
| 84 | + | // If user is null and we already have no user, skip | |
| 85 | + | if (!user && !this.currentUser) { | |
| 86 | + | console.debug('No user to set and no current user, skipping'); | |
| 87 | + | return; | |
| 71 | 88 | } | |
| 72 | - | this.messages = []; | |
| 73 | - | this.users = []; | |
| 74 | - | this.userCache = {}; | |
| 89 | + | ||
| 90 | + | // If same user is being set, skip | |
| 91 | + | if (user?.id === this.currentUser?.id) { | |
| 92 | + | console.debug('Same user being set, skipping'); | |
| 93 | + | return; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | this.currentUser = user; | |
| 97 | + | ||
| 98 | + | if (user) { | |
| 99 | + | // Update user cache with current user | |
| 100 | + | this.userCache[user.id] = user; | |
| 101 | + | ||
| 102 | + | // Only reinitialize if we have a valid user | |
| 103 | + | await this.reinitialize(); | |
| 104 | + | } else { | |
| 105 | + | // Cleanup when user is removed | |
| 106 | + | if (this.eventSource) { | |
| 107 | + | this.eventSource.close(); | |
| 108 | + | this.eventSource = null; | |
| 109 | + | } | |
| 110 | + | this.messages = []; | |
| 111 | + | this.users = []; | |
| 112 | + | this.userCache = {}; | |
| 113 | + | } | |
| 114 | + | } finally { | |
| 115 | + | this.isSettingUser = false; | |
| 75 | 116 | } | |
| 76 | - | ||
| 77 | - | // Invalidate both session and messages to trigger reloads | |
| 78 | - | await Promise.all([ | |
| 79 | - | invalidate('chat:session'), | |
| 80 | - | invalidate('chat:messages') | |
| 81 | - | ]); | |
| 82 | 117 | } | |
| 83 | 118 | ||
| 84 | 119 | private async initializeRoomUsers() { | |
| @@ -237,31 +272,91 @@ class ChatState { | |||
| 237 | 272 | ||
| 238 | 273 | // Message methods | |
| 239 | 274 | getMessages() { | |
| 240 | - | // Return messages with enriched user data | |
| 241 | - | return this.enrichMessages(this.messages); | |
| 275 | + | const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); | |
| 276 | + | if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) { | |
| 277 | + | return this._memoizedEnrichedMessages; | |
| 278 | + | } | |
| 279 | + | this._lastEnrichmentKey = key; | |
| 280 | + | console.debug('Enriching messages with user data', { | |
| 281 | + | messagesCount: this.messages.length, | |
| 282 | + | userCacheSize: Object.keys(this.userCache).length | |
| 283 | + | }); | |
| 284 | + | this._memoizedEnrichedMessages = this.enrichMessages(this.messages); | |
| 285 | + | return this._memoizedEnrichedMessages; | |
| 242 | 286 | } | |
| 243 | 287 | ||
| 244 | - | // Add method to update user cache for public access | |
| 245 | - | updateUserCache(users: User[]) { | |
| 246 | - | // Merge incoming users with the existing cache | |
| 247 | - | const mergedUsers = new Map<string, User>(); | |
| 288 | + | public prependMessages(newMessages: Message[]) { | |
| 289 | + | console.debug('Prepending messages:', { count: newMessages.length }); | |
| 290 | + | // Get unique user IDs from new messages that aren't in the cache | |
| 291 | + | const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId)); | |
| 292 | + | const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]); | |
| 248 | 293 | ||
| 249 | - | // Add all users from the existing cache | |
| 250 | - | Object.values(this.userCache).forEach(user => mergedUsers.set(user.id, user)); | |
| 294 | + | // Fetch missing user data in parallel | |
| 295 | + | if (missingUserIds.length > 0) { | |
| 296 | + | console.debug('Fetching missing user data for IDs:', missingUserIds); | |
| 297 | + | Promise.all(missingUserIds.map(async (userId) => { | |
| 298 | + | try { | |
| 299 | + | const userResponse = await fetch(`/api/users/${userId}`, { | |
| 300 | + | credentials: 'include' | |
| 301 | + | }); | |
| 302 | + | if (userResponse.ok) { | |
| 303 | + | const userData = await userResponse.json(); | |
| 304 | + | if (userData.success && userData.user) { | |
| 305 | + | this.userCache[userId] = userData.user; | |
| 306 | + | // Only add to users array if not already present | |
| 307 | + | if (!this.users.some(u => u.id === userId)) { | |
| 308 | + | this.users = [...this.users, userData.user]; | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | } | |
| 312 | + | } catch (error) { | |
| 313 | + | console.debug('Error fetching user data:', { userId, error }); | |
| 314 | + | } | |
| 315 | + | })); | |
| 316 | + | } | |
| 251 | 317 | ||
| 252 | - | // Merge with the new list of users | |
| 253 | - | users.forEach(user => mergedUsers.set(user.id, user)); | |
| 318 | + | // Prepend new messages while maintaining uniqueness | |
| 319 | + | const uniqueNewMessages = newMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); | |
| 320 | + | this.messages = [...uniqueNewMessages, ...this.messages]; | |
| 321 | + | } | |
| 254 | 322 | ||
| 255 | - | // Update this.users with all merged users | |
| 256 | - | this.users = Array.from(mergedUsers.values()); | |
| 323 | + | // Add method to update user cache for public access | |
| 324 | + | updateUserCache(users: User[]) { | |
| 325 | + | console.debug('Updating user cache with users:', users); | |
| 326 | + | // Merge incoming users with the existing cache | |
| 327 | + | users.forEach(user => { | |
| 328 | + | if (!this.userCache[user.id] || user.status !== this.userCache[user.id].status) { | |
| 329 | + | this.userCache[user.id] = user; | |
| 330 | + | // Update users array if not already present or if status changed | |
| 331 | + | const existingIndex = this.users.findIndex(u => u.id === user.id); | |
| 332 | + | if (existingIndex === -1) { | |
| 333 | + | this.users = [...this.users, user]; | |
| 334 | + | } else if (user.status !== this.users[existingIndex].status) { | |
| 335 | + | this.users = [ | |
| 336 | + | ...this.users.slice(0, existingIndex), | |
| 337 | + | user, | |
| 338 | + | ...this.users.slice(existingIndex + 1) | |
| 339 | + | ]; | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | }); | |
| 343 | + | } | |
| 257 | 344 | ||
| 258 | - | // Update the cache as well | |
| 259 | - | mergedUsers.forEach((user, id) => { this.userCache[id] = user; }); | |
| 345 | + | // Add method to update online users for public access | |
| 346 | + | updateOnlineUsers(users: User[]) { | |
| 347 | + | console.debug('Updating online users:', users); | |
| 348 | + | this.users = users; | |
| 349 | + | // Also update cache | |
| 350 | + | users.forEach(user => { | |
| 351 | + | this.userCache[user.id] = user; | |
| 352 | + | }); | |
| 260 | 353 | } | |
| 261 | 354 | ||
| 262 | 355 | // Add method to update messages for public access | |
| 263 | 356 | updateMessages(messages: Message[]) { | |
| 264 | - | this.messages = messages; | |
| 357 | + | // Deduplicate the messages by their id in case duplicates exist | |
| 358 | + | const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values()); | |
| 359 | + | this.messages = uniqueMessages; | |
| 265 | 360 | } | |
| 266 | 361 | ||
| 267 | 362 | async initializeMessages() { | |
| @@ -364,147 +459,146 @@ class ChatState { | |||
| 364 | 459 | ||
| 365 | 460 | private setupSSE() { | |
| 366 | 461 | console.debug('Setting up SSE connection for chat messages'); | |
| 367 | - | // Clear any existing connection timeout | |
| 462 | + | ||
| 463 | + | // Clear any existing timeouts | |
| 368 | 464 | if (this.connectionTimeout) { | |
| 369 | 465 | clearTimeout(this.connectionTimeout); | |
| 370 | 466 | this.connectionTimeout = null; | |
| 371 | 467 | } | |
| 372 | 468 | ||
| 373 | - | // Add a small delay before connecting to prevent rapid reconnection attempts | |
| 374 | - | this.connectionTimeout = setTimeout(() => { | |
| 469 | + | if (this.connectionAttemptTimeout) { | |
| 470 | + | clearTimeout(this.connectionAttemptTimeout); | |
| 471 | + | this.connectionAttemptTimeout = null; | |
| 472 | + | } | |
| 473 | + | ||
| 474 | + | // Debounce connection attempts | |
| 475 | + | this.connectionAttemptTimeout = setTimeout(() => { | |
| 476 | + | if (!this.currentUser) { | |
| 477 | + | console.debug('No current user, skipping SSE connection'); | |
| 478 | + | return; | |
| 479 | + | } | |
| 480 | + | ||
| 481 | + | if (this.isConnecting) { | |
| 482 | + | console.debug('Already connecting to SSE, skipping'); | |
| 483 | + | return; | |
| 484 | + | } | |
| 485 | + | ||
| 375 | 486 | this.connectSSE(); | |
| 376 | - | }, 500); | |
| 487 | + | }, 1000); | |
| 377 | 488 | } | |
| 378 | 489 | ||
| 379 | - | private connectSSE() { | |
| 380 | - | if (this.eventSource) { | |
| 381 | - | this.eventSource.close(); | |
| 382 | - | this.eventSource = null; | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | if (this.connectionTimeout) { | |
| 386 | - | clearTimeout(this.connectionTimeout); | |
| 387 | - | this.connectionTimeout = null; | |
| 490 | + | private async connectSSE() { | |
| 491 | + | if (this.isConnecting) { | |
| 492 | + | console.debug('Already connecting to SSE, skipping'); | |
| 493 | + | return; | |
| 388 | 494 | } | |
| 389 | - | ||
| 495 | + | ||
| 496 | + | this.isConnecting = true; | |
| 497 | + | ||
| 390 | 498 | try { | |
| 499 | + | // Ensure clean disconnect of any existing connection | |
| 500 | + | if (this.eventSource) { | |
| 501 | + | console.debug('Closing existing SSE connection'); | |
| 502 | + | this.eventSource.close(); | |
| 503 | + | this.eventSource = null; | |
| 504 | + | } | |
| 505 | + | ||
| 391 | 506 | console.debug('Connecting to SSE...'); | |
| 392 | 507 | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | |
| 393 | - | this.isReconnecting = false; | |
| 394 | - | ||
| 508 | + | ||
| 395 | 509 | this.eventSource.onopen = () => { | |
| 396 | 510 | console.debug('SSE connection established'); | |
| 397 | 511 | this.reconnectAttempts = 0; | |
| 398 | 512 | this.reconnectDelay = 1000; | |
| 399 | 513 | this.sseError = null; | |
| 400 | 514 | this.sseRetryAfter = null; | |
| 515 | + | this.isReconnecting = false; | |
| 401 | 516 | }; | |
| 402 | - | ||
| 517 | + | ||
| 518 | + | // Set up error handling | |
| 403 | 519 | this.eventSource.onerror = (error) => { | |
| 404 | 520 | console.debug('SSE connection error:', error); | |
| 405 | 521 | if (this.eventSource?.readyState === EventSource.CLOSED) { | |
| 406 | 522 | this.handleDisconnection(); | |
| 407 | 523 | } | |
| 408 | 524 | }; | |
| 525 | + | ||
| 526 | + | // Set up message handling | |
| 527 | + | this.setupMessageHandlers(this.eventSource); | |
| 528 | + | ||
| 529 | + | } catch (error) { | |
| 530 | + | console.debug('Error setting up SSE connection:', error); | |
| 531 | + | this.handleDisconnection(); | |
| 532 | + | } finally { | |
| 533 | + | this.isConnecting = false; | |
| 534 | + | } | |
| 535 | + | } | |
| 409 | 536 | ||
| 410 | - | // Set a connection timeout | |
| 411 | - | this.connectionTimeout = setTimeout(() => { | |
| 412 | - | if (this.eventSource?.readyState !== EventSource.OPEN) { | |
| 413 | - | console.debug('SSE connection timeout'); | |
| 414 | - | this.handleDisconnection(); | |
| 415 | - | } | |
| 416 | - | }, 10000); // 10 second timeout | |
| 537 | + | private setupMessageHandlers(eventSource: EventSource) { | |
| 538 | + | // Listen for chat messages | |
| 539 | + | eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | |
| 540 | + | try { | |
| 541 | + | const messageData = JSON.parse(event.data) as Message; | |
| 542 | + | console.debug('Received chat message via SSE:', messageData); | |
| 543 | + | // Deduplicate and update messages array with the new message | |
| 544 | + | this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values()); | |
| 545 | + | ||
| 546 | + | // Ensure the sender's data is available | |
| 547 | + | await this.ensureUserData(messageData.senderId); | |
| 548 | + | } catch (error) { | |
| 549 | + | console.debug('Error handling SSE chat message:', error); | |
| 550 | + | } | |
| 551 | + | }); | |
| 552 | + | } | |
| 417 | 553 | ||
| 418 | - | // Listen for chat messages | |
| 419 | - | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | |
| 420 | - | try { | |
| 421 | - | const messageData = JSON.parse(event.data) as Message; | |
| 422 | - | // Ensure the sender's data is available | |
| 423 | - | if (!this.userCache[messageData.senderId]) { | |
| 424 | - | console.debug('Fetching user data for new message:', messageData.senderId); | |
| 425 | - | const response = await fetch(`/api/users/${messageData.senderId}`, { credentials: 'include' }); | |
| 426 | - | if (response.ok) { | |
| 427 | - | const userData = await response.json(); | |
| 428 | - | if (userData.success && userData.user) { | |
| 429 | - | this.userCache[userData.user.id] = userData.user; | |
| 430 | - | if (!this.users.some(u => u.id === userData.user.id)) { | |
| 431 | - | this.users = [...this.users, userData.user]; | |
| 432 | - | } | |
| 433 | - | } | |
| 434 | - | } | |
| 435 | - | } | |
| 436 | - | this.messages = [...this.messages, messageData]; | |
| 437 | - | await invalidate('chat:messages'); | |
| 438 | - | } catch (error) { | |
| 439 | - | console.debug('Error parsing chatMessage SSE data:', error); | |
| 440 | - | } | |
| 554 | + | private async ensureUserData(userId: string) { | |
| 555 | + | if (this.userCache[userId]) return; | |
| 556 | + | ||
| 557 | + | console.debug('Fetching user data for:', userId); | |
| 558 | + | try { | |
| 559 | + | const response = await fetch(`/api/users/${userId}`, { | |
| 560 | + | credentials: 'include' | |
| 441 | 561 | }); | |
| 442 | - | ||
| 443 | - | // Listen for user status updates | |
| 444 | - | this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => { | |
| 445 | - | try { | |
| 446 | - | const userUpdate = JSON.parse(event.data); | |
| 447 | - | console.debug('Received SSE user status update:', userUpdate); | |
| 448 | - | this.updateUserStatus(userUpdate.userId, userUpdate.status, userUpdate.lastSeen); | |
| 449 | - | } catch (error) { | |
| 450 | - | console.debug('Error processing userStatusUpdate event:', error); | |
| 562 | + | if (response.ok) { | |
| 563 | + | const userData = await response.json(); | |
| 564 | + | if (userData.success && userData.user) { | |
| 565 | + | this.userCache[userId] = userData.user; | |
| 566 | + | if (!this.users.some(u => u.id === userId)) { | |
| 567 | + | this.users = [...this.users, userData.user]; | |
| 568 | + | } | |
| 451 | 569 | } | |
| 452 | - | }); | |
| 570 | + | } | |
| 453 | 571 | } catch (error) { | |
| 454 | - | console.error('Error setting up SSE connection:', error); | |
| 455 | - | this.handleDisconnection(); | |
| 572 | + | console.debug('Error fetching user data:', error); | |
| 456 | 573 | } | |
| 457 | 574 | } | |
| 458 | 575 | ||
| 459 | 576 | private handleDisconnection() { | |
| 460 | - | if (this.isReconnecting) return; | |
| 461 | - | ||
| 577 | + | console.debug('SSE connection closed, reconnecting...'); | |
| 462 | 578 | this.isReconnecting = true; | |
| 463 | - | if (this.eventSource) { | |
| 464 | - | this.eventSource.close(); | |
| 579 | + | this.reconnectAttempts++; | |
| 580 | + | if (this.reconnectAttempts > this.maxReconnectAttempts) { | |
| 581 | + | console.debug('Max reconnect attempts reached, giving up'); | |
| 465 | 582 | this.eventSource = null; | |
| 583 | + | return; | |
| 466 | 584 | } | |
| 467 | - | ||
| 468 | - | if (this.reconnectAttempts < this.maxReconnectAttempts) { | |
| 469 | - | console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`); | |
| 470 | - | setTimeout(() => { | |
| 471 | - | this.reconnectAttempts++; | |
| 472 | - | this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // Max 30 second delay | |
| 473 | - | this.connectSSE(); | |
| 474 | - | }, this.reconnectDelay); | |
| 475 | - | } else { | |
| 476 | - | console.debug('Max reconnection attempts reached'); | |
| 477 | - | this.sseError = 'Connection lost. Please refresh the page.'; | |
| 478 | - | } | |
| 585 | + | this.reconnectDelay *= 2; // Exponential backoff | |
| 586 | + | this.setupSSE(); | |
| 479 | 587 | } | |
| 480 | 588 | ||
| 481 | - | // Add new method to enrich multiple messages at once | |
| 482 | 589 | enrichMessages(messages: Message[]): EnrichedMessage[] { | |
| 483 | - | return messages.map(msg => this.enrichMessageWithUser(msg)); | |
| 484 | - | } | |
| 485 | - | ||
| 486 | - | // Add new method to efficiently get user data for messages | |
| 487 | - | enrichMessageWithUser(message: Message): EnrichedMessage { | |
| 488 | - | const user = this.getUserById(message.senderId); | |
| 489 | - | return { | |
| 590 | + | console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length }); | |
| 591 | + | return messages.map(message => ({ | |
| 490 | 592 | ...message, | |
| 491 | - | user: user || { | |
| 593 | + | user: this.userCache[message.senderId] || { | |
| 492 | 594 | id: message.senderId, | |
| 493 | 595 | nickname: 'Unknown User', | |
| 494 | 596 | status: 'offline', | |
| 495 | - | password: '', // This is a system user, no password needed | |
| 496 | - | createdAt: Date.now(), | |
| 497 | - | lastSeen: null, | |
| 498 | - | avatarUrl: null | |
| 597 | + | password: '', | |
| 598 | + | createdAt: 0 | |
| 499 | 599 | } | |
| 500 | - | }; | |
| 501 | - | } | |
| 502 | - | ||
| 503 | - | // Add getter for SSE error state | |
| 504 | - | public getSSEError() { | |
| 505 | - | return { error: this.sseError, retryAfter: this.sseRetryAfter }; | |
| 600 | + | })); | |
| 506 | 601 | } | |
| 507 | 602 | } | |
| 508 | 603 | ||
| 509 | - | // Export a singleton instance | |
| 510 | - | export const chatState = new ChatState(); ⏎̸ | |
| 604 | + | export const chatState = new ChatState();⏎̸ | |
| @@ -23,8 +23,18 @@ class ChatState { | |||||
| 23 | private sseRetryAfter = $state<number | null>(null); | 23 | private sseRetryAfter = $state<number | null>(null); | ||
| 24 | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | 24 | private connectionTimeout: ReturnType<typeof setTimeout> | null = null; | ||
| 25 | private isReconnecting = $state(false); | 25 | private isReconnecting = $state(false); | ||
| 26 | + | private isSettingUser = $state(false); | |||
| 27 | + | private isConnecting = $state(false); | |||
| 28 | + | private connectionAttemptTimeout: ReturnType<typeof setTimeout> | null = null; | |||
| 29 | + | private _memoizedEnrichedMessages: EnrichedMessage[] | null = null; | |||
| 30 | + | private _lastEnrichmentKey: string = ''; | |||
| 26 | 31 | ||||
| 27 | public async reinitialize() { | 32 | public async reinitialize() { | ||
| 33 | + | if (this.isInitializing) { | |||
| 34 | + | console.debug('Reinitialize already in progress, skipping.'); | |||
| 35 | + | return; | |||
| 36 | + | } | |||
| 37 | + | this.isInitializing = true; | |||
| 28 | console.debug('Reinitializing chat state...'); | 38 | console.debug('Reinitializing chat state...'); | ||
| 29 | if (this.eventSource) { | 39 | if (this.eventSource) { | ||
| 30 | this.eventSource.close(); | 40 | this.eventSource.close(); | ||
| @@ -34,6 +44,8 @@ class ChatState { | |||||
| 34 | this.reconnectDelay = 1000; | 44 | this.reconnectDelay = 1000; | ||
| 35 | this.messages = []; | 45 | this.messages = []; | ||
| 36 | if (this.currentUser) { | 46 | if (this.currentUser) { | ||
| 47 | + | console.debug('Waiting for session cookie update before setting up SSE...'); | |||
| 48 | + | await new Promise(resolve => setTimeout(resolve, 1000)); | |||
| 37 | // First set up SSE to receive status updates | 49 | // First set up SSE to receive status updates | ||
| 38 | this.setupSSE(); | 50 | this.setupSSE(); | ||
| 39 | // Then initialize messages and room users | 51 | // Then initialize messages and room users | ||
| @@ -42,6 +54,14 @@ class ChatState { | |||||
| 42 | this.initializeRoomUsers() | 54 | this.initializeRoomUsers() | ||
| 43 | ]); | 55 | ]); | ||
| 44 | } | 56 | } | ||
| 57 | + | this.isInitializing = false; | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | getSSEError() { | |||
| 61 | + | return { | |||
| 62 | + | error: this.sseError, | |||
| 63 | + | retryAfter: this.sseRetryAfter | |||
| 64 | + | }; | |||
| 45 | } | 65 | } | ||
| 46 | 66 | ||||
| 47 | // User methods | 67 | // User methods | ||
| @@ -51,34 +71,49 @@ class ChatState { | |||||
| 51 | 71 | ||||
| 52 | async setCurrentUser(user: User | null) { | 72 | async setCurrentUser(user: User | null) { | ||
| 53 | console.debug('setCurrentUser called with', user); | 73 | console.debug('setCurrentUser called with', user); | ||
| 54 | - | this.currentUser = user; | 74 | + | |
| 55 | - | 75 | + | // Prevent concurrent setCurrentUser calls | |
| 56 | - | if (user) { | 76 | + | if (this.isSettingUser) { |
| 57 | - | // Add the current user to the cache immediately | 77 | + | console.debug('Already setting user, skipping'); |
| 58 | - | this.userCache[user.id] = user; | 78 | + | return; |
| 59 | - | // First set up SSE to receive status updates | 79 | + | } |
| 60 | - | this.setupSSE(); | 80 | + | |
| 61 | - | // Then initialize messages and room users | 81 | + | this.isSettingUser = true; |
| 62 | - | await Promise.all([ | 82 | + | |
| 63 | - | this.initializeMessages(), | 83 | + | try { |
| 64 | - | this.initializeRoomUsers() | 84 | + | // If user is null and we already have no user, skip |
| 65 | - | ]); | 85 | + | if (!user && !this.currentUser) { |
| 66 | - | } else { | 86 | + | console.debug('No user to set and no current user, skipping'); |
| 67 | - | // If no global session, clear SSE and state | 87 | + | return; |
| 68 | - | if (this.eventSource) { | |||
| 69 | - | this.eventSource.close(); | |||
| 70 | - | this.eventSource = null; | |||
| 71 | } | 88 | } | ||
| 72 | - | this.messages = []; | 89 | + | |
| 73 | - | this.users = []; | 90 | + | // If same user is being set, skip |
| 74 | - | this.userCache = {}; | 91 | + | if (user?.id === this.currentUser?.id) { |
| 92 | + | console.debug('Same user being set, skipping'); | |||
| 93 | + | return; | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | this.currentUser = user; | |||
| 97 | + | ||||
| 98 | + | if (user) { | |||
| 99 | + | // Update user cache with current user | |||
| 100 | + | this.userCache[user.id] = user; | |||
| 101 | + | ||||
| 102 | + | // Only reinitialize if we have a valid user | |||
| 103 | + | await this.reinitialize(); | |||
| 104 | + | } else { | |||
| 105 | + | // Cleanup when user is removed | |||
| 106 | + | if (this.eventSource) { | |||
| 107 | + | this.eventSource.close(); | |||
| 108 | + | this.eventSource = null; | |||
| 109 | + | } | |||
| 110 | + | this.messages = []; | |||
| 111 | + | this.users = []; | |||
| 112 | + | this.userCache = {}; | |||
| 113 | + | } | |||
| 114 | + | } finally { | |||
| 115 | + | this.isSettingUser = false; | |||
| 75 | } | 116 | } | ||
| 76 | - | ||||
| 77 | - | // Invalidate both session and messages to trigger reloads | |||
| 78 | - | await Promise.all([ | |||
| 79 | - | invalidate('chat:session'), | |||
| 80 | - | invalidate('chat:messages') | |||
| 81 | - | ]); | |||
| 82 | } | 117 | } | ||
| 83 | 118 | ||||
| 84 | private async initializeRoomUsers() { | 119 | private async initializeRoomUsers() { | ||
| @@ -237,31 +272,91 @@ class ChatState { | |||||
| 237 | 272 | ||||
| 238 | // Message methods | 273 | // Message methods | ||
| 239 | getMessages() { | 274 | getMessages() { | ||
| 240 | - | // Return messages with enriched user data | 275 | + | const key = this.messages.map(m => m.id).join(',') + '|' + Object.keys(this.userCache).sort().join(','); |
| 241 | - | return this.enrichMessages(this.messages); | 276 | + | if (key === this._lastEnrichmentKey && this._memoizedEnrichedMessages !== null) { |
| 277 | + | return this._memoizedEnrichedMessages; | |||
| 278 | + | } | |||
| 279 | + | this._lastEnrichmentKey = key; | |||
| 280 | + | console.debug('Enriching messages with user data', { | |||
| 281 | + | messagesCount: this.messages.length, | |||
| 282 | + | userCacheSize: Object.keys(this.userCache).length | |||
| 283 | + | }); | |||
| 284 | + | this._memoizedEnrichedMessages = this.enrichMessages(this.messages); | |||
| 285 | + | return this._memoizedEnrichedMessages; | |||
| 242 | } | 286 | } | ||
| 243 | 287 | ||||
| 244 | - | // Add method to update user cache for public access | 288 | + | public prependMessages(newMessages: Message[]) { |
| 245 | - | updateUserCache(users: User[]) { | 289 | + | console.debug('Prepending messages:', { count: newMessages.length }); |
| 246 | - | // Merge incoming users with the existing cache | 290 | + | // Get unique user IDs from new messages that aren't in the cache |
| 247 | - | const mergedUsers = new Map<string, User>(); | 291 | + | const uniqueUserIds = new Set(newMessages.map(msg => msg.senderId)); |
| 292 | + | const missingUserIds = Array.from(uniqueUserIds).filter(id => !this.userCache[id]); | |||
| 248 | 293 | ||||
| 249 | - | // Add all users from the existing cache | 294 | + | // Fetch missing user data in parallel |
| 250 | - | Object.values(this.userCache).forEach(user => mergedUsers.set(user.id, user)); | 295 | + | if (missingUserIds.length > 0) { |
| 296 | + | console.debug('Fetching missing user data for IDs:', missingUserIds); | |||
| 297 | + | Promise.all(missingUserIds.map(async (userId) => { | |||
| 298 | + | try { | |||
| 299 | + | const userResponse = await fetch(`/api/users/${userId}`, { | |||
| 300 | + | credentials: 'include' | |||
| 301 | + | }); | |||
| 302 | + | if (userResponse.ok) { | |||
| 303 | + | const userData = await userResponse.json(); | |||
| 304 | + | if (userData.success && userData.user) { | |||
| 305 | + | this.userCache[userId] = userData.user; | |||
| 306 | + | // Only add to users array if not already present | |||
| 307 | + | if (!this.users.some(u => u.id === userId)) { | |||
| 308 | + | this.users = [...this.users, userData.user]; | |||
| 309 | + | } | |||
| 310 | + | } | |||
| 311 | + | } | |||
| 312 | + | } catch (error) { | |||
| 313 | + | console.debug('Error fetching user data:', { userId, error }); | |||
| 314 | + | } | |||
| 315 | + | })); | |||
| 316 | + | } | |||
| 251 | 317 | ||||
| 252 | - | // Merge with the new list of users | 318 | + | // Prepend new messages while maintaining uniqueness |
| 253 | - | users.forEach(user => mergedUsers.set(user.id, user)); | 319 | + | const uniqueNewMessages = newMessages.filter(msg => !this.messages.some(m => m.id === msg.id)); |
| 320 | + | this.messages = [...uniqueNewMessages, ...this.messages]; | |||
| 321 | + | } | |||
| 254 | 322 | ||||
| 255 | - | // Update this.users with all merged users | 323 | + | // Add method to update user cache for public access |
| 256 | - | this.users = Array.from(mergedUsers.values()); | 324 | + | updateUserCache(users: User[]) { |
| 325 | + | console.debug('Updating user cache with users:', users); | |||
| 326 | + | // Merge incoming users with the existing cache | |||
| 327 | + | users.forEach(user => { | |||
| 328 | + | if (!this.userCache[user.id] || user.status !== this.userCache[user.id].status) { | |||
| 329 | + | this.userCache[user.id] = user; | |||
| 330 | + | // Update users array if not already present or if status changed | |||
| 331 | + | const existingIndex = this.users.findIndex(u => u.id === user.id); | |||
| 332 | + | if (existingIndex === -1) { | |||
| 333 | + | this.users = [...this.users, user]; | |||
| 334 | + | } else if (user.status !== this.users[existingIndex].status) { | |||
| 335 | + | this.users = [ | |||
| 336 | + | ...this.users.slice(0, existingIndex), | |||
| 337 | + | user, | |||
| 338 | + | ...this.users.slice(existingIndex + 1) | |||
| 339 | + | ]; | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | }); | |||
| 343 | + | } | |||
| 257 | 344 | ||||
| 258 | - | // Update the cache as well | 345 | + | // Add method to update online users for public access |
| 259 | - | mergedUsers.forEach((user, id) => { this.userCache[id] = user; }); | 346 | + | updateOnlineUsers(users: User[]) { |
| 347 | + | console.debug('Updating online users:', users); | |||
| 348 | + | this.users = users; | |||
| 349 | + | // Also update cache | |||
| 350 | + | users.forEach(user => { | |||
| 351 | + | this.userCache[user.id] = user; | |||
| 352 | + | }); | |||
| 260 | } | 353 | } | ||
| 261 | 354 | ||||
| 262 | // Add method to update messages for public access | 355 | // Add method to update messages for public access | ||
| 263 | updateMessages(messages: Message[]) { | 356 | updateMessages(messages: Message[]) { | ||
| 264 | - | this.messages = messages; | 357 | + | // Deduplicate the messages by their id in case duplicates exist |
| 358 | + | const uniqueMessages = Array.from(new Map(messages.map(msg => [msg.id, msg])).values()); | |||
| 359 | + | this.messages = uniqueMessages; | |||
| 265 | } | 360 | } | ||
| 266 | 361 | ||||
| 267 | async initializeMessages() { | 362 | async initializeMessages() { | ||
| @@ -364,147 +459,146 @@ class ChatState { | |||||
| 364 | 459 | ||||
| 365 | private setupSSE() { | 460 | private setupSSE() { | ||
| 366 | console.debug('Setting up SSE connection for chat messages'); | 461 | console.debug('Setting up SSE connection for chat messages'); | ||
| 367 | - | // Clear any existing connection timeout | 462 | + | |
| 463 | + | // Clear any existing timeouts | |||
| 368 | if (this.connectionTimeout) { | 464 | if (this.connectionTimeout) { | ||
| 369 | clearTimeout(this.connectionTimeout); | 465 | clearTimeout(this.connectionTimeout); | ||
| 370 | this.connectionTimeout = null; | 466 | this.connectionTimeout = null; | ||
| 371 | } | 467 | } | ||
| 372 | 468 | ||||
| 373 | - | // Add a small delay before connecting to prevent rapid reconnection attempts | 469 | + | if (this.connectionAttemptTimeout) { |
| 374 | - | this.connectionTimeout = setTimeout(() => { | 470 | + | clearTimeout(this.connectionAttemptTimeout); |
| 471 | + | this.connectionAttemptTimeout = null; | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | // Debounce connection attempts | |||
| 475 | + | this.connectionAttemptTimeout = setTimeout(() => { | |||
| 476 | + | if (!this.currentUser) { | |||
| 477 | + | console.debug('No current user, skipping SSE connection'); | |||
| 478 | + | return; | |||
| 479 | + | } | |||
| 480 | + | ||||
| 481 | + | if (this.isConnecting) { | |||
| 482 | + | console.debug('Already connecting to SSE, skipping'); | |||
| 483 | + | return; | |||
| 484 | + | } | |||
| 485 | + | ||||
| 375 | this.connectSSE(); | 486 | this.connectSSE(); | ||
| 376 | - | }, 500); | 487 | + | }, 1000); |
| 377 | } | 488 | } | ||
| 378 | 489 | ||||
| 379 | - | private connectSSE() { | 490 | + | private async connectSSE() { |
| 380 | - | if (this.eventSource) { | 491 | + | if (this.isConnecting) { |
| 381 | - | this.eventSource.close(); | 492 | + | console.debug('Already connecting to SSE, skipping'); |
| 382 | - | this.eventSource = null; | 493 | + | return; |
| 383 | - | } | |||
| 384 | - | ||||
| 385 | - | if (this.connectionTimeout) { | |||
| 386 | - | clearTimeout(this.connectionTimeout); | |||
| 387 | - | this.connectionTimeout = null; | |||
| 388 | } | 494 | } | ||
| 389 | - | 495 | + | ||
| 496 | + | this.isConnecting = true; | |||
| 497 | + | ||||
| 390 | try { | 498 | try { | ||
| 499 | + | // Ensure clean disconnect of any existing connection | |||
| 500 | + | if (this.eventSource) { | |||
| 501 | + | console.debug('Closing existing SSE connection'); | |||
| 502 | + | this.eventSource.close(); | |||
| 503 | + | this.eventSource = null; | |||
| 504 | + | } | |||
| 505 | + | ||||
| 391 | console.debug('Connecting to SSE...'); | 506 | console.debug('Connecting to SSE...'); | ||
| 392 | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | 507 | this.eventSource = new EventSource('/api/sse', { withCredentials: true }); | ||
| 393 | - | this.isReconnecting = false; | 508 | + | |
| 394 | - | ||||
| 395 | this.eventSource.onopen = () => { | 509 | this.eventSource.onopen = () => { | ||
| 396 | console.debug('SSE connection established'); | 510 | console.debug('SSE connection established'); | ||
| 397 | this.reconnectAttempts = 0; | 511 | this.reconnectAttempts = 0; | ||
| 398 | this.reconnectDelay = 1000; | 512 | this.reconnectDelay = 1000; | ||
| 399 | this.sseError = null; | 513 | this.sseError = null; | ||
| 400 | this.sseRetryAfter = null; | 514 | this.sseRetryAfter = null; | ||
| 515 | + | this.isReconnecting = false; | |||
| 401 | }; | 516 | }; | ||
| 402 | - | 517 | + | ||
| 518 | + | // Set up error handling | |||
| 403 | this.eventSource.onerror = (error) => { | 519 | this.eventSource.onerror = (error) => { | ||
| 404 | console.debug('SSE connection error:', error); | 520 | console.debug('SSE connection error:', error); | ||
| 405 | if (this.eventSource?.readyState === EventSource.CLOSED) { | 521 | if (this.eventSource?.readyState === EventSource.CLOSED) { | ||
| 406 | this.handleDisconnection(); | 522 | this.handleDisconnection(); | ||
| 407 | } | 523 | } | ||
| 408 | }; | 524 | }; | ||
| 525 | + | ||||
| 526 | + | // Set up message handling | |||
| 527 | + | this.setupMessageHandlers(this.eventSource); | |||
| 528 | + | ||||
| 529 | + | } catch (error) { | |||
| 530 | + | console.debug('Error setting up SSE connection:', error); | |||
| 531 | + | this.handleDisconnection(); | |||
| 532 | + | } finally { | |||
| 533 | + | this.isConnecting = false; | |||
| 534 | + | } | |||
| 535 | + | } | |||
| 409 | 536 | ||||
| 410 | - | // Set a connection timeout | 537 | + | private setupMessageHandlers(eventSource: EventSource) { |
| 411 | - | this.connectionTimeout = setTimeout(() => { | 538 | + | // Listen for chat messages |
| 412 | - | if (this.eventSource?.readyState !== EventSource.OPEN) { | 539 | + | eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { |
| 413 | - | console.debug('SSE connection timeout'); | 540 | + | try { |
| 414 | - | this.handleDisconnection(); | 541 | + | const messageData = JSON.parse(event.data) as Message; |
| 415 | - | } | 542 | + | console.debug('Received chat message via SSE:', messageData); |
| 416 | - | }, 10000); // 10 second timeout | 543 | + | // Deduplicate and update messages array with the new message |
| 544 | + | this.messages = Array.from(new Map([...this.messages, messageData].map(m => [m.id, m])).values()); | |||
| 545 | + | ||||
| 546 | + | // Ensure the sender's data is available | |||
| 547 | + | await this.ensureUserData(messageData.senderId); | |||
| 548 | + | } catch (error) { | |||
| 549 | + | console.debug('Error handling SSE chat message:', error); | |||
| 550 | + | } | |||
| 551 | + | }); | |||
| 552 | + | } | |||
| 417 | 553 | ||||
| 418 | - | // Listen for chat messages | 554 | + | private async ensureUserData(userId: string) { |
| 419 | - | this.eventSource.addEventListener('chatMessage', async (event: MessageEvent) => { | 555 | + | if (this.userCache[userId]) return; |
| 420 | - | try { | 556 | + | |
| 421 | - | const messageData = JSON.parse(event.data) as Message; | 557 | + | console.debug('Fetching user data for:', userId); |
| 422 | - | // Ensure the sender's data is available | 558 | + | try { |
| 423 | - | if (!this.userCache[messageData.senderId]) { | 559 | + | const response = await fetch(`/api/users/${userId}`, { |
| 424 | - | console.debug('Fetching user data for new message:', messageData.senderId); | 560 | + | credentials: 'include' |
| 425 | - | const response = await fetch(`/api/users/${messageData.senderId}`, { credentials: 'include' }); | |||
| 426 | - | if (response.ok) { | |||
| 427 | - | const userData = await response.json(); | |||
| 428 | - | if (userData.success && userData.user) { | |||
| 429 | - | this.userCache[userData.user.id] = userData.user; | |||
| 430 | - | if (!this.users.some(u => u.id === userData.user.id)) { | |||
| 431 | - | this.users = [...this.users, userData.user]; | |||
| 432 | - | } | |||
| 433 | - | } | |||
| 434 | - | } | |||
| 435 | - | } | |||
| 436 | - | this.messages = [...this.messages, messageData]; | |||
| 437 | - | await invalidate('chat:messages'); | |||
| 438 | - | } catch (error) { | |||
| 439 | - | console.debug('Error parsing chatMessage SSE data:', error); | |||
| 440 | - | } | |||
| 441 | }); | 561 | }); | ||
| 442 | - | 562 | + | if (response.ok) { | |
| 443 | - | // Listen for user status updates | 563 | + | const userData = await response.json(); |
| 444 | - | this.eventSource.addEventListener('userStatusUpdate', (event: MessageEvent) => { | 564 | + | if (userData.success && userData.user) { |
| 445 | - | try { | 565 | + | this.userCache[userId] = userData.user; |
| 446 | - | const userUpdate = JSON.parse(event.data); | 566 | + | if (!this.users.some(u => u.id === userId)) { |
| 447 | - | console.debug('Received SSE user status update:', userUpdate); | 567 | + | this.users = [...this.users, userData.user]; |
| 448 | - | this.updateUserStatus(userUpdate.userId, userUpdate.status, userUpdate.lastSeen); | 568 | + | } |
| 449 | - | } catch (error) { | |||
| 450 | - | console.debug('Error processing userStatusUpdate event:', error); | |||
| 451 | } | 569 | } | ||
| 452 | - | }); | 570 | + | } |
| 453 | } catch (error) { | 571 | } catch (error) { | ||
| 454 | - | console.error('Error setting up SSE connection:', error); | 572 | + | console.debug('Error fetching user data:', error); |
| 455 | - | this.handleDisconnection(); | |||
| 456 | } | 573 | } | ||
| 457 | } | 574 | } | ||
| 458 | 575 | ||||
| 459 | private handleDisconnection() { | 576 | private handleDisconnection() { | ||
| 460 | - | if (this.isReconnecting) return; | 577 | + | console.debug('SSE connection closed, reconnecting...'); |
| 461 | - | ||||
| 462 | this.isReconnecting = true; | 578 | this.isReconnecting = true; | ||
| 463 | - | if (this.eventSource) { | 579 | + | this.reconnectAttempts++; |
| 464 | - | this.eventSource.close(); | 580 | + | if (this.reconnectAttempts > this.maxReconnectAttempts) { |
| 581 | + | console.debug('Max reconnect attempts reached, giving up'); | |||
| 465 | this.eventSource = null; | 582 | this.eventSource = null; | ||
| 583 | + | return; | |||
| 466 | } | 584 | } | ||
| 467 | - | 585 | + | this.reconnectDelay *= 2; // Exponential backoff | |
| 468 | - | if (this.reconnectAttempts < this.maxReconnectAttempts) { | 586 | + | this.setupSSE(); |
| 469 | - | console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`); | |||
| 470 | - | setTimeout(() => { | |||
| 471 | - | this.reconnectAttempts++; | |||
| 472 | - | this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); // Max 30 second delay | |||
| 473 | - | this.connectSSE(); | |||
| 474 | - | }, this.reconnectDelay); | |||
| 475 | - | } else { | |||
| 476 | - | console.debug('Max reconnection attempts reached'); | |||
| 477 | - | this.sseError = 'Connection lost. Please refresh the page.'; | |||
| 478 | - | } | |||
| 479 | } | 587 | } | ||
| 480 | 588 | ||||
| 481 | - | // Add new method to enrich multiple messages at once | |||
| 482 | enrichMessages(messages: Message[]): EnrichedMessage[] { | 589 | enrichMessages(messages: Message[]): EnrichedMessage[] { | ||
| 483 | - | return messages.map(msg => this.enrichMessageWithUser(msg)); | 590 | + | console.debug('Enriching messages with user data', { messagesCount: messages.length, userCacheSize: Object.keys(this.userCache).length }); |
| 484 | - | } | 591 | + | return messages.map(message => ({ |
| 485 | - | ||||
| 486 | - | // Add new method to efficiently get user data for messages | |||
| 487 | - | enrichMessageWithUser(message: Message): EnrichedMessage { | |||
| 488 | - | const user = this.getUserById(message.senderId); | |||
| 489 | - | return { | |||
| 490 | ...message, | 592 | ...message, | ||
| 491 | - | user: user || { | 593 | + | user: this.userCache[message.senderId] || { |
| 492 | id: message.senderId, | 594 | id: message.senderId, | ||
| 493 | nickname: 'Unknown User', | 595 | nickname: 'Unknown User', | ||
| 494 | status: 'offline', | 596 | status: 'offline', | ||
| 495 | - | password: '', // This is a system user, no password needed | 597 | + | password: '', |
| 496 | - | createdAt: Date.now(), | 598 | + | createdAt: 0 |
| 497 | - | lastSeen: null, | |||
| 498 | - | avatarUrl: null | |||
| 499 | } | 599 | } | ||
| 500 | - | }; | 600 | + | })); |
| 501 | - | } | |||
| 502 | - | ||||
| 503 | - | // Add getter for SSE error state | |||
| 504 | - | public getSSEError() { | |||
| 505 | - | return { error: this.sseError, retryAfter: this.sseRetryAfter }; | |||
| 506 | } | 601 | } | ||
| 507 | } | 602 | } | ||
| 508 | 603 | ||||
| 509 | - | // Export a singleton instance | 604 | + | export const chatState = new ChatState();⏎̸ |
| 510 | - | export const chatState = new ChatState(); ⏎̸ | |||
M src/routes/+layout.svelte +16 -5
| @@ -7,6 +7,7 @@ | |||
| 7 | 7 | let { children, data } = $props(); | |
| 8 | 8 | let isVisible = $state(true); | |
| 9 | 9 | let statusInterval = $state<number | undefined>(undefined); | |
| 10 | + | let lastUserUpdate = $state<string | null>(null); | |
| 10 | 11 | ||
| 11 | 12 | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | |
| 12 | 13 | if (!data.user?.id) return; | |
| @@ -112,6 +113,15 @@ | |||
| 112 | 113 | ||
| 113 | 114 | $effect(() => { | |
| 114 | 115 | if (!browser) return; | |
| 116 | + | ||
| 117 | + | const currentUserJson = data.user ? JSON.stringify(data.user) : null; | |
| 118 | + | if (currentUserJson === lastUserUpdate) { | |
| 119 | + | console.debug('Skipping duplicate user update'); | |
| 120 | + | return; | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | lastUserUpdate = currentUserJson; | |
| 124 | + | ||
| 115 | 125 | console.debug('Layout reactive update - Session state:', { | |
| 116 | 126 | timestamp: new Date().toISOString(), | |
| 117 | 127 | hasUser: !!data.user, | |
| @@ -128,11 +138,12 @@ | |||
| 128 | 138 | cookiePresent: document.cookie.includes('session='), | |
| 129 | 139 | isVisible | |
| 130 | 140 | }); | |
| 131 | - | }); | |
| 132 | - | ||
| 133 | - | $effect(() => { | |
| 134 | - | console.debug('Updating global chatState with data.user:', data.user); | |
| 135 | - | chatState.setCurrentUser(data.user); | |
| 141 | + | ||
| 142 | + | // Only update chat state if we have valid session data | |
| 143 | + | if (data.session?.id) { | |
| 144 | + | console.debug('Updating global chatState with data.user:', data.user); | |
| 145 | + | chatState.setCurrentUser(data.user); | |
| 146 | + | } | |
| 136 | 147 | }); | |
| 137 | 148 | </script> | |
| 138 | 149 | ||
| @@ -7,6 +7,7 @@ | |||||
| 7 | let { children, data } = $props(); | 7 | let { children, data } = $props(); | ||
| 8 | let isVisible = $state(true); | 8 | let isVisible = $state(true); | ||
| 9 | let statusInterval = $state<number | undefined>(undefined); | 9 | let statusInterval = $state<number | undefined>(undefined); | ||
| 10 | + | let lastUserUpdate = $state<string | null>(null); | |||
| 10 | 11 | ||||
| 11 | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | 12 | async function updateUserStatus(status: 'online' | 'offline' | 'busy') { | ||
| 12 | if (!data.user?.id) return; | 13 | if (!data.user?.id) return; | ||
| @@ -112,6 +113,15 @@ | |||||
| 112 | 113 | ||||
| 113 | $effect(() => { | 114 | $effect(() => { | ||
| 114 | if (!browser) return; | 115 | if (!browser) return; | ||
| 116 | + | ||||
| 117 | + | const currentUserJson = data.user ? JSON.stringify(data.user) : null; | |||
| 118 | + | if (currentUserJson === lastUserUpdate) { | |||
| 119 | + | console.debug('Skipping duplicate user update'); | |||
| 120 | + | return; | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | lastUserUpdate = currentUserJson; | |||
| 124 | + | ||||
| 115 | console.debug('Layout reactive update - Session state:', { | 125 | console.debug('Layout reactive update - Session state:', { | ||
| 116 | timestamp: new Date().toISOString(), | 126 | timestamp: new Date().toISOString(), | ||
| 117 | hasUser: !!data.user, | 127 | hasUser: !!data.user, | ||
| @@ -128,11 +138,12 @@ | |||||
| 128 | cookiePresent: document.cookie.includes('session='), | 138 | cookiePresent: document.cookie.includes('session='), | ||
| 129 | isVisible | 139 | isVisible | ||
| 130 | }); | 140 | }); | ||
| 131 | - | }); | 141 | + | |
| 132 | - | 142 | + | // Only update chat state if we have valid session data | |
| 133 | - | $effect(() => { | 143 | + | if (data.session?.id) { |
| 134 | - | console.debug('Updating global chatState with data.user:', data.user); | 144 | + | console.debug('Updating global chatState with data.user:', data.user); |
| 135 | - | chatState.setCurrentUser(data.user); | 145 | + | chatState.setCurrentUser(data.user); |
| 146 | + | } | |||
| 136 | }); | 147 | }); | ||
| 137 | </script> | 148 | </script> | ||
| 138 | 149 | ||||
M src/routes/api/chat/messages/+server.ts +50 -12
| @@ -10,7 +10,7 @@ import type { | |||
| 10 | 10 | import { desc } from 'drizzle-orm'; | |
| 11 | 11 | import { error } from '@sveltejs/kit'; | |
| 12 | 12 | import { sseEmitter } from '$lib/sseEmitter'; | |
| 13 | - | import { eq } from 'drizzle-orm'; | |
| 13 | + | import { eq, lt, and } from 'drizzle-orm'; | |
| 14 | 14 | import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema'; | |
| 15 | 15 | import { users } from '$lib/db/schema'; | |
| 16 | 16 | import { ensureDefaultChatRoom } from '$lib/db/schema'; | |
| @@ -78,8 +78,10 @@ async function initializeCache() { | |||
| 78 | 78 | ||
| 79 | 79 | // GET endpoint: fetch messages from cache or DB | |
| 80 | 80 | export async function GET({ request, locals }) { | |
| 81 | - | const isPublic = new URL(request.url).searchParams.get('public') === 'true'; | |
| 82 | - | const roomId = new URL(request.url).searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | |
| 81 | + | const url = new URL(request.url); | |
| 82 | + | const isPublic = url.searchParams.get('public') === 'true'; | |
| 83 | + | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | |
| 84 | + | const beforeTimestamp = url.searchParams.get('before'); | |
| 83 | 85 | ||
| 84 | 86 | if (!isPublic && !locals.user) { | |
| 85 | 87 | throw error(401, 'Authentication required'); | |
| @@ -94,19 +96,48 @@ export async function GET({ request, locals }) { | |||
| 94 | 96 | if (!isCacheInitialized) { | |
| 95 | 97 | await initializeCache(); | |
| 96 | 98 | } | |
| 97 | - | console.log('[Chat] Fetching public messages from cache', { roomId }); | |
| 98 | - | fetchedMessages = messageCache.getMessages(roomId, 10); | |
| 99 | + | ||
| 100 | + | if (beforeTimestamp) { | |
| 101 | + | // For public users requesting older messages, fetch from DB with limit | |
| 102 | + | console.log('[Chat] Fetching public messages from DB before timestamp:', beforeTimestamp); | |
| 103 | + | const query = db.select() | |
| 104 | + | .from(messages) | |
| 105 | + | .where(and( | |
| 106 | + | eq(messages.chatRoomId, roomId), | |
| 107 | + | lt(messages.timestamp, parseInt(beforeTimestamp)) | |
| 108 | + | )) | |
| 109 | + | .orderBy(desc(messages.timestamp)) | |
| 110 | + | .limit(10); | |
| 111 | + | fetchedMessages = (await query).reverse(); | |
| 112 | + | } else { | |
| 113 | + | console.log('[Chat] Fetching public messages from cache', { roomId }); | |
| 114 | + | fetchedMessages = messageCache.getMessages(roomId, 10); | |
| 115 | + | } | |
| 99 | 116 | } else { | |
| 100 | 117 | if (!locals.user) { | |
| 101 | 118 | throw error(401, 'Authentication required'); | |
| 102 | 119 | } | |
| 103 | - | console.log('[Chat] Fetching messages from DB for instant chat', { roomId }); | |
| 104 | - | const query = db.select() | |
| 105 | - | .from(messages) | |
| 106 | - | .where(eq(messages.chatRoomId, roomId)) | |
| 107 | - | .orderBy(desc(messages.timestamp)) | |
| 108 | - | .limit(50); | |
| 109 | - | fetchedMessages = (await query).reverse(); | |
| 120 | + | ||
| 121 | + | if (beforeTimestamp) { | |
| 122 | + | console.log('[Chat] Fetching messages from DB before timestamp:', beforeTimestamp); | |
| 123 | + | const query = db.select() | |
| 124 | + | .from(messages) | |
| 125 | + | .where(and( | |
| 126 | + | eq(messages.chatRoomId, roomId), | |
| 127 | + | lt(messages.timestamp, parseInt(beforeTimestamp)) | |
| 128 | + | )) | |
| 129 | + | .orderBy(desc(messages.timestamp)) | |
| 130 | + | .limit(100); | |
| 131 | + | fetchedMessages = (await query).reverse(); | |
| 132 | + | } else { | |
| 133 | + | console.log('[Chat] Fetching messages from DB for instant chat', { roomId }); | |
| 134 | + | const query = db.select() | |
| 135 | + | .from(messages) | |
| 136 | + | .where(eq(messages.chatRoomId, roomId)) | |
| 137 | + | .orderBy(desc(messages.timestamp)) | |
| 138 | + | .limit(50); | |
| 139 | + | fetchedMessages = (await query).reverse(); | |
| 140 | + | } | |
| 110 | 141 | } | |
| 111 | 142 | ||
| 112 | 143 | const response: GetMessagesResponse = { | |
| @@ -234,6 +265,13 @@ export async function POST({ request, locals }: { request: Request, locals: App. | |||
| 234 | 265 | timestamp: newMessage.timestamp | |
| 235 | 266 | }); | |
| 236 | 267 | ||
| 268 | + | // Update message cache | |
| 269 | + | messageCache.addMessage(newMessage); | |
| 270 | + | console.debug('[Chat] Message added to cache', { | |
| 271 | + | messageId: newMessage.id, | |
| 272 | + | chatRoomId: newMessage.chatRoomId | |
| 273 | + | }); | |
| 274 | + | ||
| 237 | 275 | // Broadcast the new message via the unified SSE emitter | |
| 238 | 276 | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | |
| 239 | 277 | ||
| @@ -10,7 +10,7 @@ import type { | |||||
| 10 | import { desc } from 'drizzle-orm'; | 10 | import { desc } from 'drizzle-orm'; | ||
| 11 | import { error } from '@sveltejs/kit'; | 11 | import { error } from '@sveltejs/kit'; | ||
| 12 | import { sseEmitter } from '$lib/sseEmitter'; | 12 | import { sseEmitter } from '$lib/sseEmitter'; | ||
| 13 | - | import { eq } from 'drizzle-orm'; | 13 | + | import { eq, lt, and } from 'drizzle-orm'; |
| 14 | import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema'; | 14 | import { chatRooms, DEFAULT_CHAT_ROOM_ID } from '$lib/db/schema'; | ||
| 15 | import { users } from '$lib/db/schema'; | 15 | import { users } from '$lib/db/schema'; | ||
| 16 | import { ensureDefaultChatRoom } from '$lib/db/schema'; | 16 | import { ensureDefaultChatRoom } from '$lib/db/schema'; | ||
| @@ -78,8 +78,10 @@ async function initializeCache() { | |||||
| 78 | 78 | ||||
| 79 | // GET endpoint: fetch messages from cache or DB | 79 | // GET endpoint: fetch messages from cache or DB | ||
| 80 | export async function GET({ request, locals }) { | 80 | export async function GET({ request, locals }) { | ||
| 81 | - | const isPublic = new URL(request.url).searchParams.get('public') === 'true'; | 81 | + | const url = new URL(request.url); |
| 82 | - | const roomId = new URL(request.url).searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | 82 | + | const isPublic = url.searchParams.get('public') === 'true'; |
| 83 | + | const roomId = url.searchParams.get('roomId') || DEFAULT_CHAT_ROOM_ID; | |||
| 84 | + | const beforeTimestamp = url.searchParams.get('before'); | |||
| 83 | 85 | ||||
| 84 | if (!isPublic && !locals.user) { | 86 | if (!isPublic && !locals.user) { | ||
| 85 | throw error(401, 'Authentication required'); | 87 | throw error(401, 'Authentication required'); | ||
| @@ -94,19 +96,48 @@ export async function GET({ request, locals }) { | |||||
| 94 | if (!isCacheInitialized) { | 96 | if (!isCacheInitialized) { | ||
| 95 | await initializeCache(); | 97 | await initializeCache(); | ||
| 96 | } | 98 | } | ||
| 97 | - | console.log('[Chat] Fetching public messages from cache', { roomId }); | 99 | + | |
| 98 | - | fetchedMessages = messageCache.getMessages(roomId, 10); | 100 | + | if (beforeTimestamp) { |
| 101 | + | // For public users requesting older messages, fetch from DB with limit | |||
| 102 | + | console.log('[Chat] Fetching public messages from DB before timestamp:', beforeTimestamp); | |||
| 103 | + | const query = db.select() | |||
| 104 | + | .from(messages) | |||
| 105 | + | .where(and( | |||
| 106 | + | eq(messages.chatRoomId, roomId), | |||
| 107 | + | lt(messages.timestamp, parseInt(beforeTimestamp)) | |||
| 108 | + | )) | |||
| 109 | + | .orderBy(desc(messages.timestamp)) | |||
| 110 | + | .limit(10); | |||
| 111 | + | fetchedMessages = (await query).reverse(); | |||
| 112 | + | } else { | |||
| 113 | + | console.log('[Chat] Fetching public messages from cache', { roomId }); | |||
| 114 | + | fetchedMessages = messageCache.getMessages(roomId, 10); | |||
| 115 | + | } | |||
| 99 | } else { | 116 | } else { | ||
| 100 | if (!locals.user) { | 117 | if (!locals.user) { | ||
| 101 | throw error(401, 'Authentication required'); | 118 | throw error(401, 'Authentication required'); | ||
| 102 | } | 119 | } | ||
| 103 | - | console.log('[Chat] Fetching messages from DB for instant chat', { roomId }); | 120 | + | |
| 104 | - | const query = db.select() | 121 | + | if (beforeTimestamp) { |
| 105 | - | .from(messages) | 122 | + | console.log('[Chat] Fetching messages from DB before timestamp:', beforeTimestamp); |
| 106 | - | .where(eq(messages.chatRoomId, roomId)) | 123 | + | const query = db.select() |
| 107 | - | .orderBy(desc(messages.timestamp)) | 124 | + | .from(messages) |
| 108 | - | .limit(50); | 125 | + | .where(and( |
| 109 | - | fetchedMessages = (await query).reverse(); | 126 | + | eq(messages.chatRoomId, roomId), |
| 127 | + | lt(messages.timestamp, parseInt(beforeTimestamp)) | |||
| 128 | + | )) | |||
| 129 | + | .orderBy(desc(messages.timestamp)) | |||
| 130 | + | .limit(100); | |||
| 131 | + | fetchedMessages = (await query).reverse(); | |||
| 132 | + | } else { | |||
| 133 | + | console.log('[Chat] Fetching messages from DB for instant chat', { roomId }); | |||
| 134 | + | const query = db.select() | |||
| 135 | + | .from(messages) | |||
| 136 | + | .where(eq(messages.chatRoomId, roomId)) | |||
| 137 | + | .orderBy(desc(messages.timestamp)) | |||
| 138 | + | .limit(50); | |||
| 139 | + | fetchedMessages = (await query).reverse(); | |||
| 140 | + | } | |||
| 110 | } | 141 | } | ||
| 111 | 142 | ||||
| 112 | const response: GetMessagesResponse = { | 143 | const response: GetMessagesResponse = { | ||
| @@ -234,6 +265,13 @@ export async function POST({ request, locals }: { request: Request, locals: App. | |||||
| 234 | timestamp: newMessage.timestamp | 265 | timestamp: newMessage.timestamp | ||
| 235 | }); | 266 | }); | ||
| 236 | 267 | ||||
| 268 | + | // Update message cache | |||
| 269 | + | messageCache.addMessage(newMessage); | |||
| 270 | + | console.debug('[Chat] Message added to cache', { | |||
| 271 | + | messageId: newMessage.id, | |||
| 272 | + | chatRoomId: newMessage.chatRoomId | |||
| 273 | + | }); | |||
| 274 | + | ||||
| 237 | // Broadcast the new message via the unified SSE emitter | 275 | // Broadcast the new message via the unified SSE emitter | ||
| 238 | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | 276 | sseEmitter.emit('sse', { type: 'chatMessage', data: newMessage }); | ||
| 239 | 277 | ||||
M src/routes/api/sse/+server.ts +7 -1
| @@ -95,7 +95,7 @@ if (typeof process !== 'undefined') { | |||
| 95 | 95 | }); | |
| 96 | 96 | } | |
| 97 | 97 | ||
| 98 | - | export const GET: RequestHandler = async ({ locals }) => { | |
| 98 | + | export const GET: RequestHandler = async ({ request, locals }) => { | |
| 99 | 99 | if (!locals.user) { | |
| 100 | 100 | return new Response('Unauthorized', { status: 401 }); | |
| 101 | 101 | } | |
| @@ -145,6 +145,12 @@ export const GET: RequestHandler = async ({ locals }) => { | |||
| 145 | 145 | }; | |
| 146 | 146 | ||
| 147 | 147 | sseEmitter.addListener('sse', onSSE); | |
| 148 | + | request.signal.addEventListener('abort', () => { | |
| 149 | + | if (onSSE) { | |
| 150 | + | sseEmitter.removeListener('sse', onSSE); | |
| 151 | + | console.log('[SSE] Removed listener due to abort signal.'); | |
| 152 | + | } | |
| 153 | + | }); | |
| 148 | 154 | console.log('[SSE] Added new listener'); | |
| 149 | 155 | ||
| 150 | 156 | // Send initial connection message | |
| @@ -95,7 +95,7 @@ if (typeof process !== 'undefined') { | |||||
| 95 | }); | 95 | }); | ||
| 96 | } | 96 | } | ||
| 97 | 97 | ||||
| 98 | - | export const GET: RequestHandler = async ({ locals }) => { | 98 | + | export const GET: RequestHandler = async ({ request, locals }) => { |
| 99 | if (!locals.user) { | 99 | if (!locals.user) { | ||
| 100 | return new Response('Unauthorized', { status: 401 }); | 100 | return new Response('Unauthorized', { status: 401 }); | ||
| 101 | } | 101 | } | ||
| @@ -145,6 +145,12 @@ export const GET: RequestHandler = async ({ locals }) => { | |||||
| 145 | }; | 145 | }; | ||
| 146 | 146 | ||||
| 147 | sseEmitter.addListener('sse', onSSE); | 147 | sseEmitter.addListener('sse', onSSE); | ||
| 148 | + | request.signal.addEventListener('abort', () => { | |||
| 149 | + | if (onSSE) { | |||
| 150 | + | sseEmitter.removeListener('sse', onSSE); | |||
| 151 | + | console.log('[SSE] Removed listener due to abort signal.'); | |||
| 152 | + | } | |||
| 153 | + | }); | |||
| 148 | console.log('[SSE] Added new listener'); | 154 | console.log('[SSE] Added new listener'); | ||
| 149 | 155 | ||||
| 150 | // Send initial connection message | 156 | // Send initial connection message | ||