brunner56's picture
implement app
0bfe2e3
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'
}
});
}
};