fix(cloudflare): remove setInterval from global scope
Cloudflare Workers/Pages don't allow setInterval at module initialization. Replaced with lazy cleanup that runs on each request.
- commit
- 8b02f585c58b9c1e7a4832967b8b13c20f20088d
- author
- bnema · · 2026-01-18 21:42 UTC
- parent
- 6968ec0
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +36 -27
M src/lib/api/rate-limiter.ts +20 -15
| @@ -41,22 +41,24 @@ const RATE_LIMITS = { | |||
| 41 | 41 | // In-memory store for rate limiting | |
| 42 | 42 | const ipRequests = new Map<string, Array<{ timestamp: number }>>(); | |
| 43 | 43 | ||
| 44 | - | // Cleanup old entries every 5 minutes | |
| 45 | - | setInterval( | |
| 46 | - | () => { | |
| 47 | - | const now = Date.now(); | |
| 48 | - | for (const [ip, timestamps] of ipRequests.entries()) { | |
| 49 | - | // Remove entries older than the maximum duration we care about (5 minutes) | |
| 50 | - | const filtered = timestamps.filter((t) => now - t.timestamp < 5 * 60 * 1000); | |
| 51 | - | if (filtered.length === 0) { | |
| 52 | - | ipRequests.delete(ip); | |
| 53 | - | } else { | |
| 54 | - | ipRequests.set(ip, filtered); | |
| 55 | - | } | |
| 44 | + | // Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope) | |
| 45 | + | let lastCleanup = 0; | |
| 46 | + | const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes | |
| 47 | + | ||
| 48 | + | function cleanupStaleEntries() { | |
| 49 | + | const now = Date.now(); | |
| 50 | + | if (now - lastCleanup < CLEANUP_INTERVAL) return; | |
| 51 | + | lastCleanup = now; | |
| 52 | + | ||
| 53 | + | for (const [ip, timestamps] of ipRequests.entries()) { | |
| 54 | + | const filtered = timestamps.filter((t) => now - t.timestamp < CLEANUP_INTERVAL); | |
| 55 | + | if (filtered.length === 0) { | |
| 56 | + | ipRequests.delete(ip); | |
| 57 | + | } else { | |
| 58 | + | ipRequests.set(ip, filtered); | |
| 56 | 59 | } | |
| 57 | - | }, | |
| 58 | - | 5 * 60 * 1000 | |
| 59 | - | ); | |
| 60 | + | } | |
| 61 | + | } | |
| 60 | 62 | ||
| 61 | 63 | function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS { | |
| 62 | 64 | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { | |
| @@ -88,6 +90,9 @@ function isRateLimited( | |||
| 88 | 90 | endpointType: keyof typeof RATE_LIMITS, | |
| 89 | 91 | isAuthenticated: boolean = false | |
| 90 | 92 | ): { limited: boolean; retryAfter?: number } { | |
| 93 | + | // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility) | |
| 94 | + | cleanupStaleEntries(); | |
| 95 | + | ||
| 91 | 96 | // Skip rate limiting completely in development for localhost | |
| 92 | 97 | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | |
| 93 | 98 | return { limited: false }; | |
| @@ -41,22 +41,24 @@ const RATE_LIMITS = { | |||||
| 41 | // In-memory store for rate limiting | 41 | // In-memory store for rate limiting | ||
| 42 | const ipRequests = new Map<string, Array<{ timestamp: number }>>(); | 42 | const ipRequests = new Map<string, Array<{ timestamp: number }>>(); | ||
| 43 | 43 | ||||
| 44 | - | // Cleanup old entries every 5 minutes | 44 | + | // Lazy cleanup: remove stale entries on each check (Cloudflare Workers don't allow setInterval at global scope) |
| 45 | - | setInterval( | 45 | + | let lastCleanup = 0; |
| 46 | - | () => { | 46 | + | const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes |
| 47 | - | const now = Date.now(); | 47 | + | |
| 48 | - | for (const [ip, timestamps] of ipRequests.entries()) { | 48 | + | function cleanupStaleEntries() { |
| 49 | - | // Remove entries older than the maximum duration we care about (5 minutes) | 49 | + | const now = Date.now(); |
| 50 | - | const filtered = timestamps.filter((t) => now - t.timestamp < 5 * 60 * 1000); | 50 | + | if (now - lastCleanup < CLEANUP_INTERVAL) return; |
| 51 | - | if (filtered.length === 0) { | 51 | + | lastCleanup = now; |
| 52 | - | ipRequests.delete(ip); | 52 | + | |
| 53 | - | } else { | 53 | + | for (const [ip, timestamps] of ipRequests.entries()) { |
| 54 | - | ipRequests.set(ip, filtered); | 54 | + | const filtered = timestamps.filter((t) => now - t.timestamp < CLEANUP_INTERVAL); |
| 55 | - | } | 55 | + | if (filtered.length === 0) { |
| 56 | + | ipRequests.delete(ip); | |||
| 57 | + | } else { | |||
| 58 | + | ipRequests.set(ip, filtered); | |||
| 56 | } | 59 | } | ||
| 57 | - | }, | 60 | + | } |
| 58 | - | 5 * 60 * 1000 | 61 | + | } |
| 59 | - | ); | |||
| 60 | 62 | ||||
| 61 | function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS { | 63 | function getEndpointType(pathname: string, isPublic: boolean): keyof typeof RATE_LIMITS { | ||
| 62 | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { | 64 | if (pathname.startsWith('/api/session/login') || pathname.startsWith('/api/register')) { | ||
| @@ -88,6 +90,9 @@ function isRateLimited( | |||||
| 88 | endpointType: keyof typeof RATE_LIMITS, | 90 | endpointType: keyof typeof RATE_LIMITS, | ||
| 89 | isAuthenticated: boolean = false | 91 | isAuthenticated: boolean = false | ||
| 90 | ): { limited: boolean; retryAfter?: number } { | 92 | ): { limited: boolean; retryAfter?: number } { | ||
| 93 | + | // Lazy cleanup on each check (replaces setInterval for Cloudflare compatibility) | |||
| 94 | + | cleanupStaleEntries(); | |||
| 95 | + | ||||
| 91 | // Skip rate limiting completely in development for localhost | 96 | // Skip rate limiting completely in development for localhost | ||
| 92 | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | 97 | if (isDev && (ip === '127.0.0.1' || ip === '::1' || ip === 'localhost')) { | ||
| 93 | return { limited: false }; | 98 | return { limited: false }; | ||
M src/routes/api/session/login/+server.ts +16 -12
| @@ -14,25 +14,29 @@ const log = createLogger('login-server'); | |||
| 14 | 14 | // In-memory map to track failed login attempts per IP | |
| 15 | 15 | const loginAttempts = new Map<string, { count: number; lastAttempt: number }>(); | |
| 16 | 16 | const MAX_ATTEMPTS = 3; | |
| 17 | + | const ONE_HOUR = 60 * 60 * 1000; | |
| 17 | 18 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| 18 | 19 | ||
| 19 | - | // Cleanup old entries every hour | |
| 20 | - | setInterval( | |
| 21 | - | () => { | |
| 22 | - | const now = Date.now(); | |
| 23 | - | const ONE_HOUR = 60 * 60 * 1000; | |
| 24 | - | for (const [ip, data] of loginAttempts.entries()) { | |
| 25 | - | if (now - data.lastAttempt > ONE_HOUR) { | |
| 26 | - | loginAttempts.delete(ip); | |
| 27 | - | } | |
| 20 | + | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope | |
| 21 | + | let lastCleanup = 0; | |
| 22 | + | function cleanupOldAttempts() { | |
| 23 | + | const now = Date.now(); | |
| 24 | + | if (now - lastCleanup < ONE_HOUR) return; | |
| 25 | + | lastCleanup = now; | |
| 26 | + | ||
| 27 | + | for (const [ip, data] of loginAttempts.entries()) { | |
| 28 | + | if (now - data.lastAttempt > ONE_HOUR) { | |
| 29 | + | loginAttempts.delete(ip); | |
| 28 | 30 | } | |
| 29 | - | }, | |
| 30 | - | 60 * 60 * 1000 | |
| 31 | - | ); | |
| 31 | + | } | |
| 32 | + | } | |
| 32 | 33 | ||
| 33 | 34 | export const POST: RequestHandler = async ({ request, cookies }) => { | |
| 34 | 35 | log.debug('New login attempt received'); | |
| 35 | 36 | ||
| 37 | + | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |
| 38 | + | cleanupOldAttempts(); | |
| 39 | + | ||
| 36 | 40 | if (request.method !== 'POST') { | |
| 37 | 41 | log.warn('Invalid method used', { method: request.method }); | |
| 38 | 42 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { | |
| @@ -14,25 +14,29 @@ const log = createLogger('login-server'); | |||||
| 14 | // In-memory map to track failed login attempts per IP | 14 | // In-memory map to track failed login attempts per IP | ||
| 15 | const loginAttempts = new Map<string, { count: number; lastAttempt: number }>(); | 15 | const loginAttempts = new Map<string, { count: number; lastAttempt: number }>(); | ||
| 16 | const MAX_ATTEMPTS = 3; | 16 | const MAX_ATTEMPTS = 3; | ||
| 17 | + | const ONE_HOUR = 60 * 60 * 1000; | |||
| 17 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | 18 | const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | ||
| 18 | 19 | ||||
| 19 | - | // Cleanup old entries every hour | 20 | + | // Lazy cleanup: Cloudflare Workers don't allow setInterval at global scope |
| 20 | - | setInterval( | 21 | + | let lastCleanup = 0; |
| 21 | - | () => { | 22 | + | function cleanupOldAttempts() { |
| 22 | - | const now = Date.now(); | 23 | + | const now = Date.now(); |
| 23 | - | const ONE_HOUR = 60 * 60 * 1000; | 24 | + | if (now - lastCleanup < ONE_HOUR) return; |
| 24 | - | for (const [ip, data] of loginAttempts.entries()) { | 25 | + | lastCleanup = now; |
| 25 | - | if (now - data.lastAttempt > ONE_HOUR) { | 26 | + | |
| 26 | - | loginAttempts.delete(ip); | 27 | + | for (const [ip, data] of loginAttempts.entries()) { |
| 27 | - | } | 28 | + | if (now - data.lastAttempt > ONE_HOUR) { |
| 29 | + | loginAttempts.delete(ip); | |||
| 28 | } | 30 | } | ||
| 29 | - | }, | 31 | + | } |
| 30 | - | 60 * 60 * 1000 | 32 | + | } |
| 31 | - | ); | |||
| 32 | 33 | ||||
| 33 | export const POST: RequestHandler = async ({ request, cookies }) => { | 34 | export const POST: RequestHandler = async ({ request, cookies }) => { | ||
| 34 | log.debug('New login attempt received'); | 35 | log.debug('New login attempt received'); | ||
| 35 | 36 | ||||
| 37 | + | // Lazy cleanup on each request (replaces setInterval for Cloudflare compatibility) | |||
| 38 | + | cleanupOldAttempts(); | |||
| 39 | + | ||||
| 36 | if (request.method !== 'POST') { | 40 | if (request.method !== 'POST') { | ||
| 37 | log.warn('Invalid method used', { method: request.method }); | 41 | log.warn('Invalid method used', { method: request.method }); | ||
| 38 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { | 42 | return new Response(JSON.stringify({ error: 'Method Not Allowed' } as LoginResponseError), { | ||