feat: Implement comprehensive rate limiting for chat and SSE endpoints
- Add granular rate limiting for SSE connections with separate limits for authenticated and unauthenticated users - Enhance rate limiter to provide dynamic retry-after information - Implement client-side cooldown mechanism for message sending - Add SSE error handling with connection retry and user-friendly error messages - Update chat state and UI to display rate limit warnings and connection errors
- commit
- 1f4430fb102b49b385f0fabf350c790c8beeae71
- author
- Kit · · 2025-02-14 07:58 UTC
- parent
- 88fcae1
- signature
- SSH Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
5 files · +331 -31
M src/lib/api/rate-limiter.ts +59 -16
| @@ -11,8 +11,11 @@ const RATE_LIMITS = { | |||
| 11 | 11 | // Protected endpoints (authenticated API calls) | |
| 12 | 12 | protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute | |
| 13 | 13 | ||
| 14 | - | // SSE endpoints | |
| 15 | - | sse: { points: 2, durationMs: 60 * 1000 } // 2 connections per minute | |
| 14 | + | // SSE endpoints - separate limits for authenticated and unauthenticated users | |
| 15 | + | 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 | |
| 18 | + | } | |
| 16 | 19 | } as const; | |
| 17 | 20 | ||
| 18 | 21 | // In-memory store for rate limiting | |
| @@ -45,33 +48,57 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE | |||
| 45 | 48 | return 'protected'; | |
| 46 | 49 | } | |
| 47 | 50 | ||
| 48 | - | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS): boolean { | |
| 51 | + | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } { | |
| 49 | 52 | const now = Date.now(); | |
| 50 | - | const { points, durationMs } = RATE_LIMITS[endpointType]; | |
| 51 | 53 | ||
| 52 | - | // Get or initialize request history for this IP | |
| 53 | - | const requests = ipRequests.get(ip) || []; | |
| 54 | + | // Handle SSE endpoints differently based on authentication status | |
| 55 | + | if (endpointType === 'sse') { | |
| 56 | + | const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated; | |
| 57 | + | const { points, durationMs } = config; | |
| 58 | + | ||
| 59 | + | // Get or initialize request history for this IP | |
| 60 | + | const requests = ipRequests.get(ip) || []; | |
| 61 | + | ||
| 62 | + | // Clean old requests outside the window | |
| 63 | + | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | |
| 64 | + | ||
| 65 | + | // Check if we're over the limit | |
| 66 | + | if (validRequests.length >= points) { | |
| 67 | + | console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`); | |
| 68 | + | const oldestRequest = validRequests[0]; | |
| 69 | + | const retryAfter = durationMs - (now - oldestRequest.timestamp); | |
| 70 | + | return { limited: true, retryAfter }; | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | // Add this request | |
| 74 | + | validRequests.push({ timestamp: now }); | |
| 75 | + | ipRequests.set(ip, validRequests); | |
| 76 | + | ||
| 77 | + | return { limited: false }; | |
| 78 | + | } | |
| 54 | 79 | ||
| 55 | - | // Clean old requests outside the window | |
| 80 | + | // Handle other endpoints | |
| 81 | + | const { points, durationMs } = RATE_LIMITS[endpointType]; | |
| 82 | + | const requests = ipRequests.get(ip) || []; | |
| 56 | 83 | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | |
| 57 | 84 | ||
| 58 | - | // Check if we're over the limit | |
| 59 | 85 | if (validRequests.length >= points) { | |
| 60 | - | console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint`); | |
| 61 | - | return true; | |
| 86 | + | const oldestRequest = validRequests[0]; | |
| 87 | + | const retryAfter = durationMs - (now - oldestRequest.timestamp); | |
| 88 | + | return { limited: true, retryAfter }; | |
| 62 | 89 | } | |
| 63 | 90 | ||
| 64 | - | // Add this request | |
| 65 | 91 | validRequests.push({ timestamp: now }); | |
| 66 | 92 | ipRequests.set(ip, validRequests); | |
| 67 | 93 | ||
| 68 | - | return false; | |
| 94 | + | return { limited: false }; | |
| 69 | 95 | } | |
| 70 | 96 | ||
| 71 | 97 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | |
| 72 | 98 | const ip = event.getClientAddress(); | |
| 73 | 99 | const url = new URL(event.request.url); | |
| 74 | 100 | const isPublic = url.searchParams.get('public') === 'true'; | |
| 101 | + | const isAuthenticated = Boolean(event.locals.user); | |
| 75 | 102 | ||
| 76 | 103 | // Skip rate limiting for non-API routes | |
| 77 | 104 | if (!url.pathname.startsWith('/api')) { | |
| @@ -79,17 +106,33 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||
| 79 | 106 | } | |
| 80 | 107 | ||
| 81 | 108 | const endpointType = getEndpointType(url.pathname, isPublic); | |
| 109 | + | const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated); | |
| 82 | 110 | ||
| 83 | - | if (isRateLimited(ip, endpointType)) { | |
| 111 | + | if (limited) { | |
| 84 | 112 | console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`); | |
| 113 | + | ||
| 114 | + | // Construct a user-friendly error message | |
| 115 | + | let errorMessage = 'Too many requests. '; | |
| 116 | + | if (endpointType === 'sse') { | |
| 117 | + | errorMessage += isAuthenticated | |
| 118 | + | ? 'Please wait before attempting to reconnect to the chat.' | |
| 119 | + | : 'Please wait before attempting to view the public chat again.'; | |
| 120 | + | } else if (endpointType === 'auth') { | |
| 121 | + | errorMessage += 'Please wait before attempting to log in again.'; | |
| 122 | + | } else { | |
| 123 | + | errorMessage += 'Please try again later.'; | |
| 124 | + | } | |
| 125 | + | ||
| 85 | 126 | return new Response(JSON.stringify({ | |
| 86 | - | error: 'Too many requests', | |
| 87 | - | retryAfter: Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000) | |
| 127 | + | error: errorMessage, | |
| 128 | + | retryAfter: Math.ceil((retryAfter || 0) / 1000), | |
| 129 | + | endpointType, | |
| 130 | + | isAuthenticated | |
| 88 | 131 | }), { | |
| 89 | 132 | status: 429, | |
| 90 | 133 | headers: { | |
| 91 | 134 | 'Content-Type': 'application/json', | |
| 92 | - | 'Retry-After': Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000).toString() | |
| 135 | + | 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString() | |
| 93 | 136 | } | |
| 94 | 137 | }); | |
| 95 | 138 | } | |
| @@ -11,8 +11,11 @@ const RATE_LIMITS = { | |||||
| 11 | // Protected endpoints (authenticated API calls) | 11 | // Protected endpoints (authenticated API calls) | ||
| 12 | protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute | 12 | protected: { points: 100, durationMs: 60 * 1000 }, // 100 requests per minute | ||
| 13 | 13 | ||||
| 14 | - | // SSE endpoints | 14 | + | // SSE endpoints - separate limits for authenticated and unauthenticated users |
| 15 | - | sse: { points: 2, durationMs: 60 * 1000 } // 2 connections per minute | 15 | + | 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 | |||
| 18 | + | } | |||
| 16 | } as const; | 19 | } as const; | ||
| 17 | 20 | ||||
| 18 | // In-memory store for rate limiting | 21 | // In-memory store for rate limiting | ||
| @@ -45,33 +48,57 @@ function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE | |||||
| 45 | return 'protected'; | 48 | return 'protected'; | ||
| 46 | } | 49 | } | ||
| 47 | 50 | ||||
| 48 | - | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS): boolean { | 51 | + | function isRateLimited(ip: string, endpointType: keyof typeof RATE_LIMITS, isAuthenticated: boolean = false): { limited: boolean; retryAfter?: number } { |
| 49 | const now = Date.now(); | 52 | const now = Date.now(); | ||
| 50 | - | const { points, durationMs } = RATE_LIMITS[endpointType]; | |||
| 51 | 53 | ||||
| 52 | - | // Get or initialize request history for this IP | 54 | + | // Handle SSE endpoints differently based on authentication status |
| 53 | - | const requests = ipRequests.get(ip) || []; | 55 | + | if (endpointType === 'sse') { |
| 56 | + | const config = isAuthenticated ? RATE_LIMITS.sse.authenticated : RATE_LIMITS.sse.unauthenticated; | |||
| 57 | + | const { points, durationMs } = config; | |||
| 58 | + | ||||
| 59 | + | // Get or initialize request history for this IP | |||
| 60 | + | const requests = ipRequests.get(ip) || []; | |||
| 61 | + | ||||
| 62 | + | // Clean old requests outside the window | |||
| 63 | + | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | |||
| 64 | + | ||||
| 65 | + | // Check if we're over the limit | |||
| 66 | + | if (validRequests.length >= points) { | |||
| 67 | + | console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint (${isAuthenticated ? 'authenticated' : 'unauthenticated'})`); | |||
| 68 | + | const oldestRequest = validRequests[0]; | |||
| 69 | + | const retryAfter = durationMs - (now - oldestRequest.timestamp); | |||
| 70 | + | return { limited: true, retryAfter }; | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Add this request | |||
| 74 | + | validRequests.push({ timestamp: now }); | |||
| 75 | + | ipRequests.set(ip, validRequests); | |||
| 76 | + | ||||
| 77 | + | return { limited: false }; | |||
| 78 | + | } | |||
| 54 | 79 | ||||
| 55 | - | // Clean old requests outside the window | 80 | + | // Handle other endpoints |
| 81 | + | const { points, durationMs } = RATE_LIMITS[endpointType]; | |||
| 82 | + | const requests = ipRequests.get(ip) || []; | |||
| 56 | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | 83 | const validRequests = requests.filter(req => now - req.timestamp < durationMs); | ||
| 57 | 84 | ||||
| 58 | - | // Check if we're over the limit | |||
| 59 | if (validRequests.length >= points) { | 85 | if (validRequests.length >= points) { | ||
| 60 | - | console.debug(`Rate limit exceeded for IP ${ip} on ${endpointType} endpoint`); | 86 | + | const oldestRequest = validRequests[0]; |
| 61 | - | return true; | 87 | + | const retryAfter = durationMs - (now - oldestRequest.timestamp); |
| 88 | + | return { limited: true, retryAfter }; | |||
| 62 | } | 89 | } | ||
| 63 | 90 | ||||
| 64 | - | // Add this request | |||
| 65 | validRequests.push({ timestamp: now }); | 91 | validRequests.push({ timestamp: now }); | ||
| 66 | ipRequests.set(ip, validRequests); | 92 | ipRequests.set(ip, validRequests); | ||
| 67 | 93 | ||||
| 68 | - | return false; | 94 | + | return { limited: false }; |
| 69 | } | 95 | } | ||
| 70 | 96 | ||||
| 71 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | 97 | export async function handleRateLimit(event: RequestEvent): Promise<Response | null> { | ||
| 72 | const ip = event.getClientAddress(); | 98 | const ip = event.getClientAddress(); | ||
| 73 | const url = new URL(event.request.url); | 99 | const url = new URL(event.request.url); | ||
| 74 | const isPublic = url.searchParams.get('public') === 'true'; | 100 | const isPublic = url.searchParams.get('public') === 'true'; | ||
| 101 | + | const isAuthenticated = Boolean(event.locals.user); | |||
| 75 | 102 | ||||
| 76 | // Skip rate limiting for non-API routes | 103 | // Skip rate limiting for non-API routes | ||
| 77 | if (!url.pathname.startsWith('/api')) { | 104 | if (!url.pathname.startsWith('/api')) { | ||
| @@ -79,17 +106,33 @@ export async function handleRateLimit(event: RequestEvent): Promise<Response | n | |||||
| 79 | } | 106 | } | ||
| 80 | 107 | ||||
| 81 | const endpointType = getEndpointType(url.pathname, isPublic); | 108 | const endpointType = getEndpointType(url.pathname, isPublic); | ||
| 109 | + | const { limited, retryAfter } = isRateLimited(ip, endpointType, isAuthenticated); | |||
| 82 | 110 | ||||
| 83 | - | if (isRateLimited(ip, endpointType)) { | 111 | + | if (limited) { |
| 84 | console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`); | 112 | console.debug(`Rate limit hit for ${ip} on ${url.pathname} (${endpointType})`); | ||
| 113 | + | ||||
| 114 | + | // Construct a user-friendly error message | |||
| 115 | + | let errorMessage = 'Too many requests. '; | |||
| 116 | + | if (endpointType === 'sse') { | |||
| 117 | + | errorMessage += isAuthenticated | |||
| 118 | + | ? 'Please wait before attempting to reconnect to the chat.' | |||
| 119 | + | : 'Please wait before attempting to view the public chat again.'; | |||
| 120 | + | } else if (endpointType === 'auth') { | |||
| 121 | + | errorMessage += 'Please wait before attempting to log in again.'; | |||
| 122 | + | } else { | |||
| 123 | + | errorMessage += 'Please try again later.'; | |||
| 124 | + | } | |||
| 125 | + | ||||
| 85 | return new Response(JSON.stringify({ | 126 | return new Response(JSON.stringify({ | ||
| 86 | - | error: 'Too many requests', | 127 | + | error: errorMessage, |
| 87 | - | retryAfter: Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000) | 128 | + | retryAfter: Math.ceil((retryAfter || 0) / 1000), |
| 129 | + | endpointType, | |||
| 130 | + | isAuthenticated | |||
| 88 | }), { | 131 | }), { | ||
| 89 | status: 429, | 132 | status: 429, | ||
| 90 | headers: { | 133 | headers: { | ||
| 91 | 'Content-Type': 'application/json', | 134 | 'Content-Type': 'application/json', | ||
| 92 | - | 'Retry-After': Math.ceil(RATE_LIMITS[endpointType].durationMs / 1000).toString() | 135 | + | 'Retry-After': Math.ceil((retryAfter || 0) / 1000).toString() |
| 93 | } | 136 | } | ||
| 94 | }); | 137 | }); | ||
| 95 | } | 138 | } | ||
M src/lib/components/chat-room.svelte +174 -11
| @@ -21,6 +21,37 @@ let currentMessage = $state(''); | |||
| 21 | 21 | let showUserList = $state(false); | |
| 22 | 22 | let isMaximized = $state(false); | |
| 23 | 23 | ||
| 24 | + | // Rate limiting state | |
| 25 | + | let cooldownEndTime = $state<number | null>(null); | |
| 26 | + | let cooldownProgress = $state(0); | |
| 27 | + | let cooldownInterval: ReturnType<typeof setInterval> | null = null; | |
| 28 | + | let rateLimitWarning = $state<string | null>(null); | |
| 29 | + | ||
| 30 | + | function updateCooldownProgress() { | |
| 31 | + | if (!cooldownEndTime) return; | |
| 32 | + | ||
| 33 | + | const now = Date.now(); | |
| 34 | + | if (now >= cooldownEndTime) { | |
| 35 | + | cooldownEndTime = null; | |
| 36 | + | cooldownProgress = 0; | |
| 37 | + | rateLimitWarning = null; | |
| 38 | + | if (cooldownInterval) { | |
| 39 | + | clearInterval(cooldownInterval); | |
| 40 | + | cooldownInterval = null; | |
| 41 | + | } | |
| 42 | + | return; | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | cooldownProgress = ((cooldownEndTime - now) / 1000); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function startCooldownTimer(retryAfter: number) { | |
| 49 | + | cooldownEndTime = Date.now() + retryAfter; | |
| 50 | + | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 51 | + | cooldownInterval = setInterval(updateCooldownProgress, 100); | |
| 52 | + | updateCooldownProgress(); | |
| 53 | + | } | |
| 54 | + | ||
| 24 | 55 | let { showChatRoom = $bindable() } = $props(); | |
| 25 | 56 | ||
| 26 | 57 | function handleClose() { | |
| @@ -42,13 +73,22 @@ let visibleMessages = $derived((() => { | |||
| 42 | 73 | return result; | |
| 43 | 74 | })()); | |
| 44 | 75 | ||
| 45 | - | // Single effect to handle all state updates | |
| 76 | + | // Add SSE error state | |
| 77 | + | let sseError = $state<string | null>(null); | |
| 78 | + | let sseRetryAfter = $state<number | null>(null); | |
| 79 | + | ||
| 80 | + | // Update the effect to check for SSE errors | |
| 46 | 81 | $effect(() => { | |
| 47 | 82 | const user = chatState.getCurrentUser(); | |
| 48 | 83 | if (user?.id !== currentUser?.id) { | |
| 49 | 84 | currentUser = user; | |
| 50 | 85 | } | |
| 51 | 86 | ||
| 87 | + | // Check for SSE errors | |
| 88 | + | const { error, retryAfter } = chatState.getSSEError(); | |
| 89 | + | sseError = error; | |
| 90 | + | sseRetryAfter = retryAfter; | |
| 91 | + | ||
| 52 | 92 | // Only update messages if we have new ones | |
| 53 | 93 | const stateMessages = chatState.getMessages(); | |
| 54 | 94 | if (stateMessages.length !== messages.length) { | |
| @@ -132,21 +172,35 @@ onMount(() => { | |||
| 132 | 172 | // Add resize listener | |
| 133 | 173 | window.addEventListener('resize', handleResize); | |
| 134 | 174 | ||
| 135 | - | return () => window.removeEventListener('resize', handleResize); | |
| 175 | + | return () => { | |
| 176 | + | window.removeEventListener('resize', handleResize); | |
| 177 | + | if (cooldownInterval) clearInterval(cooldownInterval); | |
| 178 | + | }; | |
| 136 | 179 | }); | |
| 137 | 180 | ||
| 138 | 181 | // New reactive state to track when a message is being sent | |
| 139 | 182 | let isSendingMessage = $state(false); | |
| 140 | 183 | ||
| 141 | 184 | async function handleSubmit() { | |
| 142 | - | if (!currentMessage.trim()) return; | |
| 185 | + | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; | |
| 186 | + | ||
| 143 | 187 | isSendingMessage = true; | |
| 144 | 188 | try { | |
| 145 | - | await chatState.sendMessage(currentMessage); | |
| 146 | - | currentMessage = ''; | |
| 189 | + | const response = await chatState.sendMessage(currentMessage); | |
| 190 | + | if (!response.success && response.isRateLimited && response.retryAfter) { | |
| 191 | + | startCooldownTimer(response.retryAfter); | |
| 192 | + | rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...'; | |
| 193 | + | } else if (!response.success) { | |
| 194 | + | rateLimitWarning = response.error || 'Failed to send message. Please try again.'; | |
| 195 | + | setTimeout(() => rateLimitWarning = null, 3000); | |
| 196 | + | } else { | |
| 197 | + | currentMessage = ''; | |
| 198 | + | rateLimitWarning = null; | |
| 199 | + | } | |
| 147 | 200 | } catch (error) { | |
| 148 | 201 | console.error('Failed to send message:', error); | |
| 149 | - | alert('Failed to send message. Please try again.'); | |
| 202 | + | rateLimitWarning = 'Failed to send message. Please try again.'; | |
| 203 | + | setTimeout(() => rateLimitWarning = null, 3000); | |
| 150 | 204 | } finally { | |
| 151 | 205 | isSendingMessage = false; | |
| 152 | 206 | } | |
| @@ -246,7 +300,12 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 246 | 300 | ondragmove={handleDragMove} | |
| 247 | 301 | > | |
| 248 | 302 | <div class="title-bar"> | |
| 249 | - | <div class="title-bar-text">Pdr Aim - {currentUser?.nickname}</div> | |
| 303 | + | <div class="title-bar-text"> | |
| 304 | + | Pdr Aim - {currentUser?.nickname} | |
| 305 | + | {#if sseError} | |
| 306 | + | <span class="connection-error">⚠️ Connection Error</span> | |
| 307 | + | {/if} | |
| 308 | + | </div> | |
| 250 | 309 | <div class="title-bar-controls"> | |
| 251 | 310 | {#if !isMobile} | |
| 252 | 311 | <button aria-label="Minimize"></button> | |
| @@ -266,8 +325,29 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 266 | 325 | </div> | |
| 267 | 326 | ||
| 268 | 327 | <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"> | |
| 328 | + | <!-- Add error message display --> | |
| 329 | + | {#if sseError} | |
| 330 | + | <div class="error-banner"> | |
| 331 | + | {sseError} | |
| 332 | + | {#if sseRetryAfter} | |
| 333 | + | <br> | |
| 334 | + | <small>Retrying in {Math.ceil(sseRetryAfter)}s...</small> | |
| 335 | + | {/if} | |
| 336 | + | </div> | |
| 337 | + | {/if} | |
| 338 | + | ||
| 269 | 339 | <!-- Chat content area --> | |
| 270 | 340 | <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;"> | |
| 341 | + | <!-- Add rate limit warning --> | |
| 342 | + | {#if rateLimitWarning} | |
| 343 | + | <div class="rate-limit-warning" class:with-progress={cooldownEndTime}> | |
| 344 | + | <span>{rateLimitWarning}</span> | |
| 345 | + | {#if cooldownEndTime} | |
| 346 | + | <small>You can send another message in {cooldownProgress.toFixed(1)}s</small> | |
| 347 | + | {/if} | |
| 348 | + | </div> | |
| 349 | + | {/if} | |
| 350 | + | ||
| 271 | 351 | <div | |
| 272 | 352 | class="sunken-panel chat-area" | |
| 273 | 353 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | |
| @@ -297,16 +377,19 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 297 | 377 | bind:value={currentMessage} | |
| 298 | 378 | style="flex: 1;" | |
| 299 | 379 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | |
| 300 | - | placeholder="Type a message..." | |
| 301 | - | disabled={!currentUser} | |
| 380 | + | placeholder={cooldownEndTime ? `Wait ${cooldownProgress.toFixed(1)}s...` : "Type a message..."} | |
| 381 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 302 | 382 | /> | |
| 303 | 383 | <LoadingButton | |
| 304 | 384 | onclick={handleSubmit} | |
| 305 | - | disabled={!currentUser} | |
| 385 | + | disabled={!currentUser || Boolean(cooldownEndTime)} | |
| 306 | 386 | loading={isSendingMessage} | |
| 307 | - | text="Send" | |
| 387 | + | text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Send"} | |
| 308 | 388 | /> | |
| 309 | 389 | </div> | |
| 390 | + | {#if cooldownEndTime} | |
| 391 | + | <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div> | |
| 392 | + | {/if} | |
| 310 | 393 | </div> | |
| 311 | 394 | ||
| 312 | 395 | <!-- Online users list --> | |
| @@ -528,4 +611,84 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||
| 528 | 611 | margin-right: 0.5rem; | |
| 529 | 612 | font-style: italic; | |
| 530 | 613 | } | |
| 614 | + | ||
| 615 | + | .cooldown-progress { | |
| 616 | + | position: absolute; | |
| 617 | + | bottom: 0; | |
| 618 | + | left: 0; | |
| 619 | + | height: 2px; | |
| 620 | + | background: #2d31a6; | |
| 621 | + | transition: width 0.1s linear; | |
| 622 | + | } | |
| 623 | + | ||
| 624 | + | .input-container { | |
| 625 | + | position: relative; | |
| 626 | + | } | |
| 627 | + | ||
| 628 | + | .connection-error { | |
| 629 | + | font-size: 0.75rem; | |
| 630 | + | color: #ff4444; | |
| 631 | + | margin-left: 0.5rem; | |
| 632 | + | } | |
| 633 | + | ||
| 634 | + | .error-banner { | |
| 635 | + | position: absolute; | |
| 636 | + | top: 2rem; | |
| 637 | + | left: 0; | |
| 638 | + | right: 0; | |
| 639 | + | background: #ffebee; | |
| 640 | + | color: #c62828; | |
| 641 | + | padding: 0.5rem; | |
| 642 | + | text-align: center; | |
| 643 | + | z-index: 100; | |
| 644 | + | border-bottom: 1px solid #ffcdd2; | |
| 645 | + | font-size: 0.875rem; | |
| 646 | + | } | |
| 647 | + | ||
| 648 | + | .error-banner small { | |
| 649 | + | color: #666; | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | .rate-limit-warning { | |
| 653 | + | background: #fff3e0; | |
| 654 | + | color: #e65100; | |
| 655 | + | padding: 0.5rem; | |
| 656 | + | margin-bottom: 0.5rem; | |
| 657 | + | border-radius: 4px; | |
| 658 | + | font-size: 0.875rem; | |
| 659 | + | display: flex; | |
| 660 | + | flex-direction: column; | |
| 661 | + | gap: 0.25rem; | |
| 662 | + | animation: slideIn 0.3s ease-out; | |
| 663 | + | border: 1px solid #ffe0b2; | |
| 664 | + | } | |
| 665 | + | ||
| 666 | + | .rate-limit-warning.with-progress { | |
| 667 | + | border-left: 4px solid #e65100; | |
| 668 | + | } | |
| 669 | + | ||
| 670 | + | .rate-limit-warning small { | |
| 671 | + | color: #666; | |
| 672 | + | font-size: 0.75rem; | |
| 673 | + | } | |
| 674 | + | ||
| 675 | + | @keyframes slideIn { | |
| 676 | + | from { | |
| 677 | + | transform: translateY(-100%); | |
| 678 | + | opacity: 0; | |
| 679 | + | } | |
| 680 | + | to { | |
| 681 | + | transform: translateY(0); | |
| 682 | + | opacity: 1; | |
| 683 | + | } | |
| 684 | + | } | |
| 685 | + | ||
| 686 | + | .cooldown-progress { | |
| 687 | + | position: absolute; | |
| 688 | + | bottom: 0; | |
| 689 | + | left: 0; | |
| 690 | + | height: 2px; | |
| 691 | + | background: #e65100; | |
| 692 | + | transition: width 0.1s linear; | |
| 693 | + | } | |
| 531 | 694 | </style>⏎̸ | |
| @@ -21,6 +21,37 @@ let currentMessage = $state(''); | |||||
| 21 | let showUserList = $state(false); | 21 | let showUserList = $state(false); | ||
| 22 | let isMaximized = $state(false); | 22 | let isMaximized = $state(false); | ||
| 23 | 23 | ||||
| 24 | + | // Rate limiting state | |||
| 25 | + | let cooldownEndTime = $state<number | null>(null); | |||
| 26 | + | let cooldownProgress = $state(0); | |||
| 27 | + | let cooldownInterval: ReturnType<typeof setInterval> | null = null; | |||
| 28 | + | let rateLimitWarning = $state<string | null>(null); | |||
| 29 | + | ||||
| 30 | + | function updateCooldownProgress() { | |||
| 31 | + | if (!cooldownEndTime) return; | |||
| 32 | + | ||||
| 33 | + | const now = Date.now(); | |||
| 34 | + | if (now >= cooldownEndTime) { | |||
| 35 | + | cooldownEndTime = null; | |||
| 36 | + | cooldownProgress = 0; | |||
| 37 | + | rateLimitWarning = null; | |||
| 38 | + | if (cooldownInterval) { | |||
| 39 | + | clearInterval(cooldownInterval); | |||
| 40 | + | cooldownInterval = null; | |||
| 41 | + | } | |||
| 42 | + | return; | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | cooldownProgress = ((cooldownEndTime - now) / 1000); | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | function startCooldownTimer(retryAfter: number) { | |||
| 49 | + | cooldownEndTime = Date.now() + retryAfter; | |||
| 50 | + | if (cooldownInterval) clearInterval(cooldownInterval); | |||
| 51 | + | cooldownInterval = setInterval(updateCooldownProgress, 100); | |||
| 52 | + | updateCooldownProgress(); | |||
| 53 | + | } | |||
| 54 | + | ||||
| 24 | let { showChatRoom = $bindable() } = $props(); | 55 | let { showChatRoom = $bindable() } = $props(); | ||
| 25 | 56 | ||||
| 26 | function handleClose() { | 57 | function handleClose() { | ||
| @@ -42,13 +73,22 @@ let visibleMessages = $derived((() => { | |||||
| 42 | return result; | 73 | return result; | ||
| 43 | })()); | 74 | })()); | ||
| 44 | 75 | ||||
| 45 | - | // Single effect to handle all state updates | 76 | + | // Add SSE error state |
| 77 | + | let sseError = $state<string | null>(null); | |||
| 78 | + | let sseRetryAfter = $state<number | null>(null); | |||
| 79 | + | ||||
| 80 | + | // Update the effect to check for SSE errors | |||
| 46 | $effect(() => { | 81 | $effect(() => { | ||
| 47 | const user = chatState.getCurrentUser(); | 82 | const user = chatState.getCurrentUser(); | ||
| 48 | if (user?.id !== currentUser?.id) { | 83 | if (user?.id !== currentUser?.id) { | ||
| 49 | currentUser = user; | 84 | currentUser = user; | ||
| 50 | } | 85 | } | ||
| 51 | 86 | ||||
| 87 | + | // Check for SSE errors | |||
| 88 | + | const { error, retryAfter } = chatState.getSSEError(); | |||
| 89 | + | sseError = error; | |||
| 90 | + | sseRetryAfter = retryAfter; | |||
| 91 | + | ||||
| 52 | // Only update messages if we have new ones | 92 | // Only update messages if we have new ones | ||
| 53 | const stateMessages = chatState.getMessages(); | 93 | const stateMessages = chatState.getMessages(); | ||
| 54 | if (stateMessages.length !== messages.length) { | 94 | if (stateMessages.length !== messages.length) { | ||
| @@ -132,21 +172,35 @@ onMount(() => { | |||||
| 132 | // Add resize listener | 172 | // Add resize listener | ||
| 133 | window.addEventListener('resize', handleResize); | 173 | window.addEventListener('resize', handleResize); | ||
| 134 | 174 | ||||
| 135 | - | return () => window.removeEventListener('resize', handleResize); | 175 | + | return () => { |
| 176 | + | window.removeEventListener('resize', handleResize); | |||
| 177 | + | if (cooldownInterval) clearInterval(cooldownInterval); | |||
| 178 | + | }; | |||
| 136 | }); | 179 | }); | ||
| 137 | 180 | ||||
| 138 | // New reactive state to track when a message is being sent | 181 | // New reactive state to track when a message is being sent | ||
| 139 | let isSendingMessage = $state(false); | 182 | let isSendingMessage = $state(false); | ||
| 140 | 183 | ||||
| 141 | async function handleSubmit() { | 184 | async function handleSubmit() { | ||
| 142 | - | if (!currentMessage.trim()) return; | 185 | + | if (!currentMessage.trim() || !currentUser || cooldownEndTime) return; |
| 186 | + | ||||
| 143 | isSendingMessage = true; | 187 | isSendingMessage = true; | ||
| 144 | try { | 188 | try { | ||
| 145 | - | await chatState.sendMessage(currentMessage); | 189 | + | const response = await chatState.sendMessage(currentMessage); |
| 146 | - | currentMessage = ''; | 190 | + | if (!response.success && response.isRateLimited && response.retryAfter) { |
| 191 | + | startCooldownTimer(response.retryAfter); | |||
| 192 | + | rateLimitWarning = 'Whoa there! You\'re sending messages too quickly. Take a breather...'; | |||
| 193 | + | } else if (!response.success) { | |||
| 194 | + | rateLimitWarning = response.error || 'Failed to send message. Please try again.'; | |||
| 195 | + | setTimeout(() => rateLimitWarning = null, 3000); | |||
| 196 | + | } else { | |||
| 197 | + | currentMessage = ''; | |||
| 198 | + | rateLimitWarning = null; | |||
| 199 | + | } | |||
| 147 | } catch (error) { | 200 | } catch (error) { | ||
| 148 | console.error('Failed to send message:', error); | 201 | console.error('Failed to send message:', error); | ||
| 149 | - | alert('Failed to send message. Please try again.'); | 202 | + | rateLimitWarning = 'Failed to send message. Please try again.'; |
| 203 | + | setTimeout(() => rateLimitWarning = null, 3000); | |||
| 150 | } finally { | 204 | } finally { | ||
| 151 | isSendingMessage = false; | 205 | isSendingMessage = false; | ||
| 152 | } | 206 | } | ||
| @@ -246,7 +300,12 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 246 | ondragmove={handleDragMove} | 300 | ondragmove={handleDragMove} | ||
| 247 | > | 301 | > | ||
| 248 | <div class="title-bar"> | 302 | <div class="title-bar"> | ||
| 249 | - | <div class="title-bar-text">Pdr Aim - {currentUser?.nickname}</div> | 303 | + | <div class="title-bar-text"> |
| 304 | + | Pdr Aim - {currentUser?.nickname} | |||
| 305 | + | {#if sseError} | |||
| 306 | + | <span class="connection-error">⚠️ Connection Error</span> | |||
| 307 | + | {/if} | |||
| 308 | + | </div> | |||
| 250 | <div class="title-bar-controls"> | 309 | <div class="title-bar-controls"> | ||
| 251 | {#if !isMobile} | 310 | {#if !isMobile} | ||
| 252 | <button aria-label="Minimize"></button> | 311 | <button aria-label="Minimize"></button> | ||
| @@ -266,8 +325,29 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 266 | </div> | 325 | </div> | ||
| 267 | 326 | ||||
| 268 | <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"> | 327 | <div class="window-body" style="display: flex; height: calc(100% - 2rem); margin: 0; padding: 0.5rem;"> | ||
| 328 | + | <!-- Add error message display --> | |||
| 329 | + | {#if sseError} | |||
| 330 | + | <div class="error-banner"> | |||
| 331 | + | {sseError} | |||
| 332 | + | {#if sseRetryAfter} | |||
| 333 | + | <br> | |||
| 334 | + | <small>Retrying in {Math.ceil(sseRetryAfter)}s...</small> | |||
| 335 | + | {/if} | |||
| 336 | + | </div> | |||
| 337 | + | {/if} | |||
| 338 | + | ||||
| 269 | <!-- Chat content area --> | 339 | <!-- Chat content area --> | ||
| 270 | <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;"> | 340 | <div class="chat-container" style="flex: 1; display: flex; flex-direction: column; margin-right: 0.5rem;"> | ||
| 341 | + | <!-- Add rate limit warning --> | |||
| 342 | + | {#if rateLimitWarning} | |||
| 343 | + | <div class="rate-limit-warning" class:with-progress={cooldownEndTime}> | |||
| 344 | + | <span>{rateLimitWarning}</span> | |||
| 345 | + | {#if cooldownEndTime} | |||
| 346 | + | <small>You can send another message in {cooldownProgress.toFixed(1)}s</small> | |||
| 347 | + | {/if} | |||
| 348 | + | </div> | |||
| 349 | + | {/if} | |||
| 350 | + | ||||
| 271 | <div | 351 | <div | ||
| 272 | class="sunken-panel chat-area" | 352 | class="sunken-panel chat-area" | ||
| 273 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | 353 | style="flex: 1; margin-bottom: 0.5rem; padding: 0.5rem; overflow-y: auto;" | ||
| @@ -297,16 +377,19 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 297 | bind:value={currentMessage} | 377 | bind:value={currentMessage} | ||
| 298 | style="flex: 1;" | 378 | style="flex: 1;" | ||
| 299 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | 379 | onkeydown={(e) => e.key === 'Enter' && handleSubmit()} | ||
| 300 | - | placeholder="Type a message..." | 380 | + | placeholder={cooldownEndTime ? `Wait ${cooldownProgress.toFixed(1)}s...` : "Type a message..."} |
| 301 | - | disabled={!currentUser} | 381 | + | disabled={!currentUser || Boolean(cooldownEndTime)} |
| 302 | /> | 382 | /> | ||
| 303 | <LoadingButton | 383 | <LoadingButton | ||
| 304 | onclick={handleSubmit} | 384 | onclick={handleSubmit} | ||
| 305 | - | disabled={!currentUser} | 385 | + | disabled={!currentUser || Boolean(cooldownEndTime)} |
| 306 | loading={isSendingMessage} | 386 | loading={isSendingMessage} | ||
| 307 | - | text="Send" | 387 | + | text={cooldownEndTime ? `${cooldownProgress.toFixed(1)}s` : "Send"} |
| 308 | /> | 388 | /> | ||
| 309 | </div> | 389 | </div> | ||
| 390 | + | {#if cooldownEndTime} | |||
| 391 | + | <div class="cooldown-progress" style="width: {100 - (cooldownProgress * 100 / (cooldownEndTime - Date.now()) * 1000)}%"></div> | |||
| 392 | + | {/if} | |||
| 310 | </div> | 393 | </div> | ||
| 311 | 394 | ||||
| 312 | <!-- Online users list --> | 395 | <!-- Online users list --> | ||
| @@ -528,4 +611,84 @@ function handleMaximizeEvent(event: CustomEvent<{ | |||||
| 528 | margin-right: 0.5rem; | 611 | margin-right: 0.5rem; | ||
| 529 | font-style: italic; | 612 | font-style: italic; | ||
| 530 | } | 613 | } | ||
| 614 | + | ||||
| 615 | + | .cooldown-progress { | |||
| 616 | + | position: absolute; | |||
| 617 | + | bottom: 0; | |||
| 618 | + | left: 0; | |||
| 619 | + | height: 2px; | |||
| 620 | + | background: #2d31a6; | |||
| 621 | + | transition: width 0.1s linear; | |||
| 622 | + | } | |||
| 623 | + | ||||
| 624 | + | .input-container { | |||
| 625 | + | position: relative; | |||
| 626 | + | } | |||
| 627 | + | ||||
| 628 | + | .connection-error { | |||
| 629 | + | font-size: 0.75rem; | |||
| 630 | + | color: #ff4444; | |||
| 631 | + | margin-left: 0.5rem; | |||
| 632 | + | } | |||
| 633 | + | ||||
| 634 | + | .error-banner { | |||
| 635 | + | position: absolute; | |||
| 636 | + | top: 2rem; | |||
| 637 | + | left: 0; | |||
| 638 | + | right: 0; | |||
| 639 | + | background: #ffebee; | |||
| 640 | + | color: #c62828; | |||
| 641 | + | padding: 0.5rem; | |||
| 642 | + | text-align: center; | |||
| 643 | + | z-index: 100; | |||
| 644 | + | border-bottom: 1px solid #ffcdd2; | |||
| 645 | + | font-size: 0.875rem; | |||
| 646 | + | } | |||
| 647 | + | ||||
| 648 | + | .error-banner small { | |||
| 649 | + | color: #666; | |||
| 650 | + | } | |||
| 651 | + | ||||
| 652 | + | .rate-limit-warning { | |||
| 653 | + | background: #fff3e0; | |||
| 654 | + | color: #e65100; | |||
| 655 | + | padding: 0.5rem; | |||
| 656 | + | margin-bottom: 0.5rem; | |||
| 657 | + | border-radius: 4px; | |||
| 658 | + | font-size: 0.875rem; | |||
| 659 | + | display: flex; | |||
| 660 | + | flex-direction: column; | |||
| 661 | + | gap: 0.25rem; | |||
| 662 | + | animation: slideIn 0.3s ease-out; | |||
| 663 | + | border: 1px solid #ffe0b2; | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | .rate-limit-warning.with-progress { | |||
| 667 | + | border-left: 4px solid #e65100; | |||
| 668 | + | } | |||
| 669 | + | ||||
| 670 | + | .rate-limit-warning small { | |||
| 671 | + | color: #666; | |||
| 672 | + | font-size: 0.75rem; | |||
| 673 | + | } | |||
| 674 | + | ||||
| 675 | + | @keyframes slideIn { | |||
| 676 | + | from { | |||
| 677 | + | transform: translateY(-100%); | |||
| 678 | + | opacity: 0; | |||
| 679 | + | } | |||
| 680 | + | to { | |||
| 681 | + | transform: translateY(0); | |||
| 682 | + | opacity: 1; | |||
| 683 | + | } | |||
| 684 | + | } | |||
| 685 | + | ||||
| 686 | + | .cooldown-progress { | |||
| 687 | + | position: absolute; | |||
| 688 | + | bottom: 0; | |||
| 689 | + | left: 0; | |||
| 690 | + | height: 2px; | |||
| 691 | + | background: #e65100; | |||
| 692 | + | transition: width 0.1s linear; | |||
| 693 | + | } | |||
| 531 | </style>⏎̸ | 694 | </style>⏎̸ | ||
M src/lib/states/chat.svelte.ts +45 -4
| @@ -19,6 +19,8 @@ class ChatState { | |||
| 19 | 19 | private isInitializing = $state(false); | |
| 20 | 20 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | |
| 21 | 21 | private userCache = $state<Record<string, User>>({}); | |
| 22 | + | private sseError = $state<string | null>(null); | |
| 23 | + | private sseRetryAfter = $state<number | null>(null); | |
| 22 | 24 | ||
| 23 | 25 | public async reinitialize() { | |
| 24 | 26 | console.debug('Reinitializing chat state...'); | |
| @@ -300,11 +302,14 @@ class ChatState { | |||
| 300 | 302 | } | |
| 301 | 303 | } | |
| 302 | 304 | ||
| 303 | - | async sendMessage(content: string, type: Message['type'] = 'chat') { | |
| 305 | + | async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> { | |
| 304 | 306 | const user = this.getCurrentUser(); | |
| 305 | 307 | if (!user) { | |
| 306 | 308 | console.debug('Cannot send message: No current user'); | |
| 307 | - | return; | |
| 309 | + | return { | |
| 310 | + | success: false, | |
| 311 | + | error: 'Not logged in' | |
| 312 | + | }; | |
| 308 | 313 | } | |
| 309 | 314 | ||
| 310 | 315 | try { | |
| @@ -338,9 +343,13 @@ class ChatState { | |||
| 338 | 343 | ||
| 339 | 344 | console.debug('Message sent successfully:', data.message); | |
| 340 | 345 | await invalidate('chat:messages'); | |
| 346 | + | return data; | |
| 341 | 347 | } catch (error) { | |
| 342 | 348 | console.debug('Error sending message:', error); | |
| 343 | - | throw error; | |
| 349 | + | return { | |
| 350 | + | success: false, | |
| 351 | + | error: 'Failed to send message' | |
| 352 | + | }; | |
| 344 | 353 | } | |
| 345 | 354 | } | |
| 346 | 355 | ||
| @@ -410,20 +419,45 @@ class ChatState { | |||
| 410 | 419 | }; | |
| 411 | 420 | } | |
| 412 | 421 | ||
| 413 | - | private handleSSEError() { | |
| 422 | + | private async handleSSEError() { | |
| 414 | 423 | if (this.eventSource) { | |
| 415 | 424 | this.eventSource.close(); | |
| 416 | 425 | this.eventSource = null; | |
| 417 | 426 | } | |
| 418 | 427 | ||
| 428 | + | // Check if the error was due to rate limiting | |
| 429 | + | try { | |
| 430 | + | const response = await fetch('/api/sse'); | |
| 431 | + | if (response.status === 429) { | |
| 432 | + | const data = await response.json(); | |
| 433 | + | this.sseError = data.error; | |
| 434 | + | this.sseRetryAfter = data.retryAfter; | |
| 435 | + | ||
| 436 | + | // Set up automatic retry after the rate limit expires | |
| 437 | + | setTimeout(() => { | |
| 438 | + | this.sseError = null; | |
| 439 | + | this.sseRetryAfter = null; | |
| 440 | + | this.reconnectSSE(); | |
| 441 | + | }, data.retryAfter * 1000); | |
| 442 | + | ||
| 443 | + | return; | |
| 444 | + | } | |
| 445 | + | } catch (error) { | |
| 446 | + | console.debug('Error checking SSE status:', error); | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | // Handle other types of errors with exponential backoff | |
| 419 | 450 | if (this.reconnectAttempts < this.maxReconnectAttempts) { | |
| 451 | + | this.sseError = `Connection lost. Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`; | |
| 420 | 452 | console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`); | |
| 453 | + | ||
| 421 | 454 | setTimeout(() => { | |
| 422 | 455 | this.reconnectAttempts++; | |
| 423 | 456 | this.reconnectDelay *= 2; // Exponential backoff | |
| 424 | 457 | this.connectSSE(); | |
| 425 | 458 | }, this.reconnectDelay); | |
| 426 | 459 | } else { | |
| 460 | + | this.sseError = 'Unable to connect to chat. Please refresh the page to try again.'; | |
| 427 | 461 | console.debug('Max reconnection attempts reached'); | |
| 428 | 462 | } | |
| 429 | 463 | } | |
| @@ -431,6 +465,8 @@ class ChatState { | |||
| 431 | 465 | private reconnectSSE() { | |
| 432 | 466 | this.reconnectAttempts = 0; | |
| 433 | 467 | this.reconnectDelay = 1000; | |
| 468 | + | this.sseError = null; | |
| 469 | + | this.sseRetryAfter = null; | |
| 434 | 470 | this.connectSSE(); | |
| 435 | 471 | } | |
| 436 | 472 | ||
| @@ -455,6 +491,11 @@ class ChatState { | |||
| 455 | 491 | } | |
| 456 | 492 | }; | |
| 457 | 493 | } | |
| 494 | + | ||
| 495 | + | // Add getter for SSE error state | |
| 496 | + | public getSSEError() { | |
| 497 | + | return { error: this.sseError, retryAfter: this.sseRetryAfter }; | |
| 498 | + | } | |
| 458 | 499 | } | |
| 459 | 500 | ||
| 460 | 501 | // Export a singleton instance | |
| @@ -19,6 +19,8 @@ class ChatState { | |||||
| 19 | private isInitializing = $state(false); | 19 | private isInitializing = $state(false); | ||
| 20 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | 20 | private currentRoomId = $state<string>(DEFAULT_CHAT_ROOM_ID); | ||
| 21 | private userCache = $state<Record<string, User>>({}); | 21 | private userCache = $state<Record<string, User>>({}); | ||
| 22 | + | private sseError = $state<string | null>(null); | |||
| 23 | + | private sseRetryAfter = $state<number | null>(null); | |||
| 22 | 24 | ||||
| 23 | public async reinitialize() { | 25 | public async reinitialize() { | ||
| 24 | console.debug('Reinitializing chat state...'); | 26 | console.debug('Reinitializing chat state...'); | ||
| @@ -300,11 +302,14 @@ class ChatState { | |||||
| 300 | } | 302 | } | ||
| 301 | } | 303 | } | ||
| 302 | 304 | ||||
| 303 | - | async sendMessage(content: string, type: Message['type'] = 'chat') { | 305 | + | async sendMessage(content: string, type: Message['type'] = 'chat'): Promise<SendMessageResponse> { |
| 304 | const user = this.getCurrentUser(); | 306 | const user = this.getCurrentUser(); | ||
| 305 | if (!user) { | 307 | if (!user) { | ||
| 306 | console.debug('Cannot send message: No current user'); | 308 | console.debug('Cannot send message: No current user'); | ||
| 307 | - | return; | 309 | + | return { |
| 310 | + | success: false, | |||
| 311 | + | error: 'Not logged in' | |||
| 312 | + | }; | |||
| 308 | } | 313 | } | ||
| 309 | 314 | ||||
| 310 | try { | 315 | try { | ||
| @@ -338,9 +343,13 @@ class ChatState { | |||||
| 338 | 343 | ||||
| 339 | console.debug('Message sent successfully:', data.message); | 344 | console.debug('Message sent successfully:', data.message); | ||
| 340 | await invalidate('chat:messages'); | 345 | await invalidate('chat:messages'); | ||
| 346 | + | return data; | |||
| 341 | } catch (error) { | 347 | } catch (error) { | ||
| 342 | console.debug('Error sending message:', error); | 348 | console.debug('Error sending message:', error); | ||
| 343 | - | throw error; | 349 | + | return { |
| 350 | + | success: false, | |||
| 351 | + | error: 'Failed to send message' | |||
| 352 | + | }; | |||
| 344 | } | 353 | } | ||
| 345 | } | 354 | } | ||
| 346 | 355 | ||||
| @@ -410,20 +419,45 @@ class ChatState { | |||||
| 410 | }; | 419 | }; | ||
| 411 | } | 420 | } | ||
| 412 | 421 | ||||
| 413 | - | private handleSSEError() { | 422 | + | private async handleSSEError() { |
| 414 | if (this.eventSource) { | 423 | if (this.eventSource) { | ||
| 415 | this.eventSource.close(); | 424 | this.eventSource.close(); | ||
| 416 | this.eventSource = null; | 425 | this.eventSource = null; | ||
| 417 | } | 426 | } | ||
| 418 | 427 | ||||
| 428 | + | // Check if the error was due to rate limiting | |||
| 429 | + | try { | |||
| 430 | + | const response = await fetch('/api/sse'); | |||
| 431 | + | if (response.status === 429) { | |||
| 432 | + | const data = await response.json(); | |||
| 433 | + | this.sseError = data.error; | |||
| 434 | + | this.sseRetryAfter = data.retryAfter; | |||
| 435 | + | ||||
| 436 | + | // Set up automatic retry after the rate limit expires | |||
| 437 | + | setTimeout(() => { | |||
| 438 | + | this.sseError = null; | |||
| 439 | + | this.sseRetryAfter = null; | |||
| 440 | + | this.reconnectSSE(); | |||
| 441 | + | }, data.retryAfter * 1000); | |||
| 442 | + | ||||
| 443 | + | return; | |||
| 444 | + | } | |||
| 445 | + | } catch (error) { | |||
| 446 | + | console.debug('Error checking SSE status:', error); | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | // Handle other types of errors with exponential backoff | |||
| 419 | if (this.reconnectAttempts < this.maxReconnectAttempts) { | 450 | if (this.reconnectAttempts < this.maxReconnectAttempts) { | ||
| 451 | + | this.sseError = `Connection lost. Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`; | |||
| 420 | console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`); | 452 | console.debug(`Attempting to reconnect (${this.reconnectAttempts + 1}/${this.maxReconnectAttempts})...`); | ||
| 453 | + | ||||
| 421 | setTimeout(() => { | 454 | setTimeout(() => { | ||
| 422 | this.reconnectAttempts++; | 455 | this.reconnectAttempts++; | ||
| 423 | this.reconnectDelay *= 2; // Exponential backoff | 456 | this.reconnectDelay *= 2; // Exponential backoff | ||
| 424 | this.connectSSE(); | 457 | this.connectSSE(); | ||
| 425 | }, this.reconnectDelay); | 458 | }, this.reconnectDelay); | ||
| 426 | } else { | 459 | } else { | ||
| 460 | + | this.sseError = 'Unable to connect to chat. Please refresh the page to try again.'; | |||
| 427 | console.debug('Max reconnection attempts reached'); | 461 | console.debug('Max reconnection attempts reached'); | ||
| 428 | } | 462 | } | ||
| 429 | } | 463 | } | ||
| @@ -431,6 +465,8 @@ class ChatState { | |||||
| 431 | private reconnectSSE() { | 465 | private reconnectSSE() { | ||
| 432 | this.reconnectAttempts = 0; | 466 | this.reconnectAttempts = 0; | ||
| 433 | this.reconnectDelay = 1000; | 467 | this.reconnectDelay = 1000; | ||
| 468 | + | this.sseError = null; | |||
| 469 | + | this.sseRetryAfter = null; | |||
| 434 | this.connectSSE(); | 470 | this.connectSSE(); | ||
| 435 | } | 471 | } | ||
| 436 | 472 | ||||
| @@ -455,6 +491,11 @@ class ChatState { | |||||
| 455 | } | 491 | } | ||
| 456 | }; | 492 | }; | ||
| 457 | } | 493 | } | ||
| 494 | + | ||||
| 495 | + | // Add getter for SSE error state | |||
| 496 | + | public getSSEError() { | |||
| 497 | + | return { error: this.sseError, retryAfter: this.sseRetryAfter }; | |||
| 498 | + | } | |||
| 458 | } | 499 | } | ||
| 459 | 500 | ||||
| 460 | // Export a singleton instance | 501 | // Export a singleton instance | ||
M src/lib/types/payloads.ts +2 -0
| @@ -54,6 +54,8 @@ export interface SendMessageResponseSuccess { | |||
| 54 | 54 | export interface SendMessageResponseError { | |
| 55 | 55 | success: false; | |
| 56 | 56 | error: string; | |
| 57 | + | retryAfter?: number; // Time in ms until next allowed message | |
| 58 | + | isRateLimited?: boolean; | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError; | |
| @@ -54,6 +54,8 @@ export interface SendMessageResponseSuccess { | |||||
| 54 | export interface SendMessageResponseError { | 54 | export interface SendMessageResponseError { | ||
| 55 | success: false; | 55 | success: false; | ||
| 56 | error: string; | 56 | error: string; | ||
| 57 | + | retryAfter?: number; // Time in ms until next allowed message | |||
| 58 | + | isRateLimited?: boolean; | |||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError; | 61 | export type SendMessageResponse = SendMessageResponseSuccess | SendMessageResponseError; | ||
M src/routes/api/chat/messages/+server.ts +51 -0
| @@ -15,6 +15,38 @@ 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'; | |
| 17 | 17 | ||
| 18 | + | // Rate limiting configuration | |
| 19 | + | const INITIAL_COOLDOWN = 1000; // 1 second | |
| 20 | + | const MAX_COOLDOWN = 30000; // 30 seconds | |
| 21 | + | const userMessageTimestamps = new Map<string, { lastMessageTime: number; currentCooldown: number }>(); | |
| 22 | + | ||
| 23 | + | function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } { | |
| 24 | + | const now = Date.now(); | |
| 25 | + | const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN }; | |
| 26 | + | ||
| 27 | + | // Check if enough time has passed since the last message | |
| 28 | + | const timeSinceLastMessage = now - userState.lastMessageTime; | |
| 29 | + | if (timeSinceLastMessage < userState.currentCooldown) { | |
| 30 | + | return { | |
| 31 | + | canSend: false, | |
| 32 | + | retryAfter: userState.currentCooldown - timeSinceLastMessage | |
| 33 | + | }; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // Reset cooldown if it's been long enough | |
| 37 | + | if (timeSinceLastMessage > userState.currentCooldown * 2) { | |
| 38 | + | userState.currentCooldown = INITIAL_COOLDOWN; | |
| 39 | + | } else { | |
| 40 | + | // Exponential backoff | |
| 41 | + | userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN); | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | userState.lastMessageTime = now; | |
| 45 | + | userMessageTimestamps.set(userId, userState); | |
| 46 | + | ||
| 47 | + | return { canSend: true }; | |
| 48 | + | } | |
| 49 | + | ||
| 18 | 50 | // GET endpoint: fetch messages from the DB | |
| 19 | 51 | export async function GET({ request, locals }) { | |
| 20 | 52 | const isPublic = new URL(request.url).searchParams.get('public') === 'true'; | |
| @@ -75,6 +107,25 @@ export async function POST({ request, locals }: { request: Request, locals: App. | |||
| 75 | 107 | ||
| 76 | 108 | console.debug('Received new message POST'); | |
| 77 | 109 | try { | |
| 110 | + | // Check rate limiting | |
| 111 | + | const { canSend, retryAfter } = updateUserCooldown(locals.user.id); | |
| 112 | + | if (!canSend) { | |
| 113 | + | console.debug('Rate limited:', { userId: locals.user.id, retryAfter }); | |
| 114 | + | const errorResponse: SendMessageResponse = { | |
| 115 | + | success: false, | |
| 116 | + | error: 'Please wait before sending another message', | |
| 117 | + | retryAfter, | |
| 118 | + | isRateLimited: true | |
| 119 | + | }; | |
| 120 | + | return new Response(JSON.stringify(errorResponse), { | |
| 121 | + | status: 429, | |
| 122 | + | headers: { | |
| 123 | + | 'Content-Type': 'application/json', | |
| 124 | + | 'Retry-After': Math.ceil(retryAfter! / 1000).toString() | |
| 125 | + | } | |
| 126 | + | }); | |
| 127 | + | } | |
| 128 | + | ||
| 78 | 129 | // Ensure default chat room exists | |
| 79 | 130 | await ensureDefaultChatRoom(db); | |
| 80 | 131 | ||
| @@ -15,6 +15,38 @@ 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'; | ||
| 17 | 17 | ||||
| 18 | + | // Rate limiting configuration | |||
| 19 | + | const INITIAL_COOLDOWN = 1000; // 1 second | |||
| 20 | + | const MAX_COOLDOWN = 30000; // 30 seconds | |||
| 21 | + | const userMessageTimestamps = new Map<string, { lastMessageTime: number; currentCooldown: number }>(); | |||
| 22 | + | ||||
| 23 | + | function updateUserCooldown(userId: string): { canSend: boolean; retryAfter?: number } { | |||
| 24 | + | const now = Date.now(); | |||
| 25 | + | const userState = userMessageTimestamps.get(userId) || { lastMessageTime: 0, currentCooldown: INITIAL_COOLDOWN }; | |||
| 26 | + | ||||
| 27 | + | // Check if enough time has passed since the last message | |||
| 28 | + | const timeSinceLastMessage = now - userState.lastMessageTime; | |||
| 29 | + | if (timeSinceLastMessage < userState.currentCooldown) { | |||
| 30 | + | return { | |||
| 31 | + | canSend: false, | |||
| 32 | + | retryAfter: userState.currentCooldown - timeSinceLastMessage | |||
| 33 | + | }; | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // Reset cooldown if it's been long enough | |||
| 37 | + | if (timeSinceLastMessage > userState.currentCooldown * 2) { | |||
| 38 | + | userState.currentCooldown = INITIAL_COOLDOWN; | |||
| 39 | + | } else { | |||
| 40 | + | // Exponential backoff | |||
| 41 | + | userState.currentCooldown = Math.min(userState.currentCooldown * 2, MAX_COOLDOWN); | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | userState.lastMessageTime = now; | |||
| 45 | + | userMessageTimestamps.set(userId, userState); | |||
| 46 | + | ||||
| 47 | + | return { canSend: true }; | |||
| 48 | + | } | |||
| 49 | + | ||||
| 18 | // GET endpoint: fetch messages from the DB | 50 | // GET endpoint: fetch messages from the DB | ||
| 19 | export async function GET({ request, locals }) { | 51 | export async function GET({ request, locals }) { | ||
| 20 | const isPublic = new URL(request.url).searchParams.get('public') === 'true'; | 52 | const isPublic = new URL(request.url).searchParams.get('public') === 'true'; | ||
| @@ -75,6 +107,25 @@ export async function POST({ request, locals }: { request: Request, locals: App. | |||||
| 75 | 107 | ||||
| 76 | console.debug('Received new message POST'); | 108 | console.debug('Received new message POST'); | ||
| 77 | try { | 109 | try { | ||
| 110 | + | // Check rate limiting | |||
| 111 | + | const { canSend, retryAfter } = updateUserCooldown(locals.user.id); | |||
| 112 | + | if (!canSend) { | |||
| 113 | + | console.debug('Rate limited:', { userId: locals.user.id, retryAfter }); | |||
| 114 | + | const errorResponse: SendMessageResponse = { | |||
| 115 | + | success: false, | |||
| 116 | + | error: 'Please wait before sending another message', | |||
| 117 | + | retryAfter, | |||
| 118 | + | isRateLimited: true | |||
| 119 | + | }; | |||
| 120 | + | return new Response(JSON.stringify(errorResponse), { | |||
| 121 | + | status: 429, | |||
| 122 | + | headers: { | |||
| 123 | + | 'Content-Type': 'application/json', | |||
| 124 | + | 'Retry-After': Math.ceil(retryAfter! / 1000).toString() | |||
| 125 | + | } | |||
| 126 | + | }); | |||
| 127 | + | } | |||
| 128 | + | ||||
| 78 | // Ensure default chat room exists | 129 | // Ensure default chat room exists | ||
| 79 | await ensureDefaultChatRoom(db); | 130 | await ensureDefaultChatRoom(db); | ||
| 80 | 131 | ||||