Spaces:
Build error
Build error
File size: 9,976 Bytes
0bfe2e3 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 |
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<string, HealthStatus>();
// 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<Response> {
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'
}
});
}
}; |