export interface Env { // Environment variables BACKEND_CF: string; BACKEND_KOYEB: string; BACKEND_DUCK: string; STICKY_SESSIONS: boolean; SESSION_COOKIE_NAME: string; BACKEND_DOWN_TIME: string; MAX_RETRIES: string; // Optional duration for sticky session cookie in seconds (default: 1 day) SESSION_COOKIE_TTL?: string; // Primary domain that the worker is handling PRIMARY_DOMAIN: string; } // Store for tracking backend health status interface HealthStatus { isDown: boolean; lastFailure: number; } // Health status for each backend const backendHealth = new Map(); // Helper function to choose a backend function chooseBackend(request: Request, env: Env): string { const backendOptions = [ env.BACKEND_CF, env.BACKEND_KOYEB, env.BACKEND_DUCK ]; // Filter out any backends that are marked as down const availableBackends = backendOptions.filter(backend => { const health = backendHealth.get(backend); if (!health) return true; if (health.isDown) { // Check if the backend has been down long enough to retry const downTime = parseInt(env.BACKEND_DOWN_TIME) || 30000; if (Date.now() - health.lastFailure > downTime) { // Reset the backend status backendHealth.set(backend, { isDown: false, lastFailure: 0 }); return true; } return false; } return true; }); // If no backends are available, reset all backends and try again if (availableBackends.length === 0) { backendOptions.forEach(backend => { backendHealth.set(backend, { isDown: false, lastFailure: 0 }); }); return backendOptions[0]; } // Check for sticky session cookie if enabled if (env.STICKY_SESSIONS) { const cookies = request.headers.get('Cookie') || ''; const cookieRegex = new RegExp(`${env.SESSION_COOKIE_NAME}=([^;]+)`); const match = cookies.match(cookieRegex); if (match && match[1]) { const preferredBackend = match[1]; // Check if the preferred backend is available if (availableBackends.includes(preferredBackend)) { return preferredBackend; } } } // Use client IP for consistent hashing if no cookie or preferred backend is down const clientIP = request.headers.get('CF-Connecting-IP') || request.headers.get('X-Real-IP') || request.headers.get('X-Forwarded-For')?.split(',')[0].trim() || 'unknown'; // Simple hash function for the client IP const hashCode = (str: string) => { let hash = 0; for (let i = 0; i < str.length; i++) { const char = str.charCodeAt(i); hash = ((hash << 5) - hash) + char; hash = hash & hash; // Convert to 32bit integer } return Math.abs(hash); }; const index = hashCode(clientIP) % availableBackends.length; return availableBackends[index]; } // Mark a backend as down function markBackendDown(backend: string, env: Env): void { backendHealth.set(backend, { isDown: true, lastFailure: Date.now() }); console.error(`Backend ${backend} marked as down at ${new Date().toISOString()}`); } // Clone request with new URL function createBackendRequest(request: Request, backend: string): Request { const url = new URL(request.url); const backendUrl = new URL(`https://${backend}`); // Preserve path and query parameters backendUrl.pathname = url.pathname; backendUrl.search = url.search; // Get original headers and create a new headers object const headers = new Headers(request.headers); // Set the host header to the backend hostname headers.set('Host', backend); // Add proxy headers headers.set('X-Forwarded-Host', url.hostname); headers.set('X-Forwarded-Proto', url.protocol.replace(':', '')); // Check if we need to handle WebSockets const upgradeHeader = request.headers.get('Upgrade'); const isWebSocket = upgradeHeader !== null && upgradeHeader.toLowerCase() === 'websocket'; // Clone the request with the new URL const newRequest = new Request(backendUrl.toString(), { method: request.method, headers: headers, body: request.body, redirect: 'manual', // Don't follow redirects automatically // If this is a WebSocket request, we need to preserve the upgrade header duplex: isWebSocket ? 'half' : undefined }); return newRequest; } // Determine if we're in a development environment function isDevelopment(): boolean { try { // Check if we're in a browser-like environment with location object // @ts-ignore - Cloudflare Workers have location in dev/preview but not in TypeScript defs return typeof globalThis.location === 'object' && // @ts-ignore (globalThis.location.hostname === 'localhost' || // @ts-ignore globalThis.location.hostname.includes('workers.dev') || // @ts-ignore globalThis.location.hostname.includes('preview')); } catch (e) { return false; } } export default { async fetch(request: Request, env: Env, ctx: any): Promise { const url = new URL(request.url); const isDevEnvironment = isDevelopment(); // In production, only handle requests for the PRIMARY_DOMAIN // In development, handle all requests (to make testing easier) if (!isDevEnvironment && url.hostname !== env.PRIMARY_DOMAIN) { console.log(`Request for ${url.hostname} rejected (expected ${env.PRIMARY_DOMAIN})`); return new Response(`This worker is configured to handle requests for ${env.PRIMARY_DOMAIN} only`, { status: 404, headers: { 'Content-Type': 'text/plain' } }); } // Redirect HTTP to HTTPS in production if (!isDevEnvironment && url.protocol === 'http:') { url.protocol = 'https:'; return Response.redirect(url.toString(), 301); } // Check for WebSocket upgrade const upgradeHeader = request.headers.get('Upgrade'); const isWebSocket = upgradeHeader !== null && upgradeHeader.toLowerCase() === 'websocket'; // Try each backend until success or we run out of retries let backend = chooseBackend(request, env); let attempts = 0; const maxRetries = parseInt(env.MAX_RETRIES) || 3; // For WebSockets, we only try once per backend to avoid connection issues const effectiveMaxRetries = isWebSocket ? Math.min(maxRetries, 1) : maxRetries; console.log(`Routing request to ${backend} (attempt 1/${effectiveMaxRetries})`); while (attempts < effectiveMaxRetries) { attempts++; try { // Create a new request for the backend const backendRequest = createBackendRequest(request, backend); // Forward the request to the backend const response = await fetch(backendRequest); // If the response is a server error (5xx), mark the backend as down and try another if (response.status >= 500 && response.status < 600) { console.error(`Backend ${backend} returned ${response.status}`); markBackendDown(backend, env); // Choose a different backend for the next attempt if (attempts < effectiveMaxRetries) { backend = chooseBackend(request, env); console.log(`Retrying with ${backend} (attempt ${attempts + 1}/${effectiveMaxRetries})`); continue; } } // Clone the response so we can modify headers const clonedResponse = new Response(response.body, response); // If sticky sessions are enabled, set a cookie with the backend if (env.STICKY_SESSIONS) { // Calculate cookie expiration const ttl = parseInt(env.SESSION_COOKIE_TTL || '86400'); // Default to 1 day const expires = new Date(); expires.setSeconds(expires.getSeconds() + ttl); clonedResponse.headers.append('Set-Cookie', `${env.SESSION_COOKIE_NAME}=${backend}; Path=/; HttpOnly; SameSite=Lax; Expires=${expires.toUTCString()}`); } // For WebSocket upgrade responses, make sure we preserve the Connection and Upgrade headers if (isWebSocket && response.status === 101) { clonedResponse.headers.set('Connection', 'Upgrade'); clonedResponse.headers.set('Upgrade', 'websocket'); } console.log(`Successfully routed to ${backend}, status: ${response.status}`); return clonedResponse; } catch (error) { console.error(`Error forwarding to ${backend}:`, error); markBackendDown(backend, env); // Choose a different backend for the next attempt if (attempts < effectiveMaxRetries) { backend = chooseBackend(request, env); console.log(`Retrying with ${backend} (attempt ${attempts + 1}/${effectiveMaxRetries})`); } } } // If we've exhausted all retries, return a 502 Bad Gateway return new Response('All backends are currently unavailable', { status: 502, headers: { 'Content-Type': 'text/plain', 'Retry-After': '30' } }); } };