import { normalizeTargetUrl, isAllowedTarget, isAllowedTargetAsync, isSsrfDnsCheckEnabled, filterRequestHeaders, buildRelayRequest, createRelayResponse, createErrorResponse, createCorsPreflightResponse, getCorsHeaders, classifyFetchError, } from "./relay-utils"; import { checkBodySize } from "../middleware/body-limiter"; import { createRateLimiter } from "../middleware/rate-limiter"; import { logRelayEvent } from "../middleware/logger"; import { ProxyPool, SessionProxyPool } from "./proxy-pool"; import { handleChatCompletion, listModels } from "./ai-proxy"; import { handleAnthropicMessages } from "./anthropic-proxy"; import { fetchWithRetry } from "./fetch-utils"; // --- Types ------------------------------------------------------------------- export interface RouterEnv { PORT?: string; RELAY_TIMEOUT_MS?: string; BODY_MAX_BYTES?: string; RATE_LIMIT_MAX?: string; RATE_LIMIT_WINDOW_MS?: string; CORS_ORIGIN?: string; NODE_ENV?: string; API_KEY?: string; PROXY_LIST?: string; // Comma-separated list of proxies for serverless // Optional KV binding for rate limiter KV?: { get(key: string): Promise; put(key: string, value: any, options?: { expirationTtl?: number }): Promise; }; } // --- Global singletons (survives warm starts) -------------------------------- let rateLimiter: ReturnType | null = null; let proxyPool: ProxyPool | null = null; let sessionPool: SessionProxyPool | null = null; const SERVER_START_TIME = Date.now(); const RELAY_VERSION = "1.0.0"; // --- Helpers ----------------------------------------------------------------- function getNumericEnv(env: RouterEnv, key: keyof RouterEnv, fallback: number): number { const raw = env[key]; const val = typeof raw === "string" ? raw : (typeof process !== "undefined" ? process.env[key as string] : undefined); return Number.parseInt(val ?? String(fallback), 10); } function getEnv(env: RouterEnv, key: keyof RouterEnv, fallback: string): string { const raw = env[key]; const fromEnv = typeof raw === "string" ? raw : (typeof process !== "undefined" ? process.env[key as string] : undefined); return fromEnv ?? fallback; } function initGlobals(env: RouterEnv) { if (!rateLimiter) { const kvAdapter = env.KV ? { get: async (k: string) => { const val = await env.KV!.get(k); return val ? JSON.parse(val) : null; }, set: async (k: string, v: number[], ttl?: number) => { await env.KV!.put(k, JSON.stringify(v), { expirationTtl: ttl }); } } : undefined; rateLimiter = createRateLimiter({ maxRequests: getNumericEnv(env, "RATE_LIMIT_MAX", 100), windowMs: getNumericEnv(env, "RATE_LIMIT_WINDOW_MS", 60000), kv: kvAdapter, }); } if (!proxyPool) { proxyPool = new ProxyPool(); // For Bun (process.env.PROXY_FILE) it's loaded in index.ts, but for serverless we can load from env const proxies = getEnv(env, "PROXY_LIST", ""); if (proxies) { for (const p of proxies.split(",")) { const pt = p.trim(); if (pt) proxyPool.addProxy(pt); } } sessionPool = new SessionProxyPool(proxyPool); sessionPool.setFailureThreshold(3); } } function requireAuth(req: Request, env: RouterEnv): Response | null { const API_KEY = getEnv(env, "API_KEY", "sk-dummy-key"); const header = req.headers.get("authorization") ?? req.headers.get("x-api-key") ?? ""; const key = header.replace(/^Bearer\s+/i, "").trim(); if (key === API_KEY) return null; return new Response( JSON.stringify({ error: { message: "Unauthorized", type: "auth_error" } }), { status: 401, headers: { "Content-Type": "application/json", ...getCorsHeaders() }, }, ); } // --- Static Handlers --------------------------------------------------------- function handleHealth(): Response { return new Response( JSON.stringify({ status: "ok", uptime: Date.now() - SERVER_START_TIME, version: RELAY_VERSION, }), { status: 200, headers: { "Content-Type": "application/json", ...getCorsHeaders(), }, }, ); } function handleIndex(): Response { const html = ` Edge Proxy Relay

Edge Proxy Relay

Server is running

/health · /docs

`; return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8", }, }); } function handleDocs(isWebSocketSupported: boolean): Response { const html = ` Edge Proxy Relay — Docs

Edge Proxy Relay

Forward HTTP${isWebSocketSupported ? ' and WebSocket' : ''} requests to any target server via the x-relay-target header.

Endpoints

GET /health

Health check. Returns 200 OK with server status, uptime, and version.

GET /docs

This page.

Any Path (Catch-all Relay)

Send a request with the x-relay-target header and this proxy forwards it.

Usage — HTTP Relay

curl -s \\
	  -H "x-relay-target: https://httpbin.org" \\
	  -H "x-relay-path: /get" \\
	  "https://your-proxy.example/any/path"
HeaderRequiredDescription
x-relay-targetYesBase URL of the upstream (http:// or https://)
x-relay-pathNoPath to append (default: /)
${isWebSocketSupported ? `

Usage — WebSocket Relay

const ws = new WebSocket("wss://your-proxy.example/relay", {
	  headers: { "x-relay-target": "wss://echo-websocket.example" },
	});
	ws.onopen = () => ws.send("Hello via relay!");
	ws.onmessage = (e) => console.log("Got:", e.data);
` : `

Note: WebSocket relay is not available on this deployment.

`}

Status Codes

CodeMeaning
204CORS preflight success (OPTIONS)
400Missing x-relay-target header
403Target blocked (SSRF protection / not allowed)
413Request body exceeds size limit
429Rate limit exceeded
502Upstream network / DNS error
504Upstream timeout
`; return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8", ...getCorsHeaders(), }, }); } // --- Generic HTTP Relay ------------------------------------------------------ async function handleRelay( req: Request, env: RouterEnv, clientIP: string, ): Promise { const startTime = performance.now(); const method = req.method; const requestUrl = req.url; const RELAY_TIMEOUT_MS = getNumericEnv(env, "RELAY_TIMEOUT_MS", 30000); // -- Pre-flight CORS if (method === "OPTIONS") { return createCorsPreflightResponse(); } // -- Middleware: Body size check const bodyError = checkBodySize(req); if (bodyError) { logRelayEvent({ method, url: requestUrl, status: bodyError.status, durationMs: Math.round(performance.now() - startTime), ip: clientIP, }); return bodyError; } // -- Middleware: Rate limiting const rateCheck = await rateLimiter!.checkAsync(clientIP); if (!rateCheck.allowed) { logRelayEvent({ method, url: requestUrl, status: 429, durationMs: Math.round(performance.now() - startTime), error: "rate_limit_exceeded", ip: clientIP, }); return new Response( JSON.stringify({ error: true, code: "RATE_LIMITED", message: "Too many requests", retryAfterMs: rateCheck.retryAfterMs, }), { status: 429, headers: { "Content-Type": "application/json", ...getCorsHeaders(), "Retry-After": String( Math.ceil((rateCheck.retryAfterMs ?? 60_000) / 1000), ), }, }, ); } // -- Extract relay parameters from headers const target = req.headers.get("x-relay-target"); const relayPath = req.headers.get("x-relay-path") ?? "/"; // -- SSRF: Normalize and validate target URL const targetUrl = normalizeTargetUrl(target, relayPath); if (!targetUrl) { logRelayEvent({ method, url: requestUrl, status: 400, durationMs: Math.round(performance.now() - startTime), error: "missing_target_header", ip: clientIP, }); return createErrorResponse({ code: "INVALID_TARGET", status: 400, message: "Missing or invalid x-relay-target header", }); } if (!isAllowedTarget(targetUrl)) { logRelayEvent({ method, url: requestUrl, status: 403, durationMs: Math.round(performance.now() - startTime), error: "target_not_allowed", ip: clientIP, }); return createErrorResponse({ code: "SSRF_BLOCKED", status: 403, message: "Target domain not allowed", }); } // -- SSRF: DNS rebinding protection (optional, via SSRF_DNS_CHECK=true) ----- if (isSsrfDnsCheckEnabled()) { const asyncAllowed = await isAllowedTargetAsync(targetUrl); if (!asyncAllowed) { logRelayEvent({ method, url: requestUrl, status: 403, durationMs: Math.round(performance.now() - startTime), error: "ssrf_dns_rebinding", ip: clientIP, }); return createErrorResponse({ code: "SSRF_BLOCKED", status: 403, message: "Target resolves to private/internal IP", }); } } // -- Build the upstream request const filteredHeaders = filterRequestHeaders(req.headers); const fetchOptions = buildRelayRequest( req, filteredHeaders, RELAY_TIMEOUT_MS, ) as RequestInit & { proxy?: string }; const targetUrlString = targetUrl.toString(); // -- Execute upstream fetch with shared retry const result = await fetchWithRetry( targetUrlString, fetchOptions, proxyPool!, "relay", ); if (result.errorClassification) { logRelayEvent({ method, url: requestUrl, status: result.errorClassification.status, durationMs: Math.round(performance.now() - startTime), error: result.errorClassification.message, targetUrl: targetUrlString, ip: clientIP, }); return createErrorResponse(result.errorClassification); } const relayedResponse = createRelayResponse(result.response!); logRelayEvent({ method, url: requestUrl, status: relayedResponse.status, durationMs: Math.round(performance.now() - startTime), targetUrl: targetUrlString, ip: clientIP, }); return relayedResponse; } // --- Main Router ------------------------------------------------------------- export interface RouterOptions { isWebSocketSupported?: boolean; getTestApiHtml?: () => string | Promise; } export async function handleRequest( req: Request, env: RouterEnv, clientIP: string, options: RouterOptions = {}, ): Promise { initGlobals(env); const url = new URL(req.url); // Static routes if (url.pathname === "/health") return handleHealth(); if (url.pathname === "/docs") return handleDocs(options.isWebSocketSupported ?? false); if (url.pathname === "/test" && options.getTestApiHtml) { const html = await options.getTestApiHtml(); return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8" }, }); } if (url.pathname === "/" && req.method === "GET" && !req.headers.get("x-relay-target")) { return handleIndex(); } // AI proxy routes -- OpenAI-compatible API if (url.pathname === "/v1/chat/completions") { if (req.method === "OPTIONS") return createCorsPreflightResponse(); if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); const authErr = requireAuth(req, env); if (authErr) return authErr; try { const body = await req.json(); const sessionId = crypto.randomUUID(); return handleChatCompletion(body, proxyPool!, sessionPool!, sessionId); } catch { return new Response( JSON.stringify({ error: { message: "Invalid JSON body", type: "invalid_request_error" } }), { status: 400, headers: { "Content-Type": "application/json", ...getCorsHeaders() } }, ); } } // AI proxy routes -- Anthropic-compatible API if (url.pathname === "/v1/messages") { if (req.method === "OPTIONS") return createCorsPreflightResponse(); if (req.method !== "POST") return new Response("Method Not Allowed", { status: 405 }); const authErr = requireAuth(req, env); if (authErr) return authErr; try { const body = await req.json(); const sessionId = crypto.randomUUID(); const anthropicVersion = req.headers.get("anthropic-version") ?? undefined; return handleAnthropicMessages(body, proxyPool!, sessionPool!, sessionId, undefined, anthropicVersion); } catch { return new Response( JSON.stringify({ type: "error", error: { message: "Invalid JSON body", type: "invalid_request_error" }, }), { status: 400, headers: { "Content-Type": "application/json", ...getCorsHeaders() } }, ); } } if (url.pathname === "/v1/models" && req.method === "GET") { const authErr = requireAuth(req, env); if (authErr) return authErr; return new Response( JSON.stringify({ object: "list", data: listModels().map((id) => ({ id, object: "model", created: Math.floor(Date.now() / 1000), owned_by: "edge-proxy", })), }), { status: 200, headers: { "Content-Type": "application/json", ...getCorsHeaders(), }, }, ); } // WebSocket upgrade check (Bun specific - worker/vercel should handle their own rejection if needed) if ( req.method === "GET" && req.headers.get("upgrade")?.toLowerCase() === "websocket" ) { if (!options.isWebSocketSupported) { return new Response( JSON.stringify({ error: true, code: "UNSUPPORTED", message: "WebSocket relay is not supported on this deployment", }), { status: 400, headers: { "Content-Type": "application/json", ...getCorsHeaders(), }, }, ); } // Return undefined to let Bun handle the upgrade in its fetch method return undefined; } // Generic HTTP relay return handleRelay(req, env, clientIP); } // Ensure proxyPool is available for index.ts to use proxyPool.tryLoad() export function getSharedProxyPool() { if (!proxyPool) { proxyPool = new ProxyPool(); sessionPool = new SessionProxyPool(proxyPool); sessionPool.setFailureThreshold(3); } return proxyPool; }