diff --git a/api/relay.ts b/api/relay.ts new file mode 100644 index 0000000..f8a7f92 --- /dev/null +++ b/api/relay.ts @@ -0,0 +1,388 @@ +/** + * Vercel-compatible relay handler. + * + * This file is the entry point for Vercel serverless function deployments. + * It exports a `default` fetch handler that Vercel invokes per-request. + * + * It reuses the same relay logic from `src/lib/` and `src/middleware/` as + * the standalone Bun.serve() server, but: + * - Does NOT call Bun.serve() (Vercel manages the server) + * - Does NOT support WebSocket upgrades (not available in Vercel Functions) + * - Uses a simplified IP detection (no server.requestIP()) + * - Rate limiter resets on cold starts (per-instance memory) + */ + +import { + normalizeTargetUrl, + isAllowedTarget, + filterRequestHeaders, + buildRelayRequest, + createRelayResponse, + classifyFetchError, + createErrorResponse, + createCorsPreflightResponse, +} from "../src/lib/relay-utils"; + +import { checkBodySize } from "../src/middleware/body-limiter"; +import { createRateLimiter } from "../src/middleware/rate-limiter"; +import { logRelayEvent } from "../src/middleware/logger"; + +// ─── Configuration ────────────────────────────────────────────────────────────── + +const RELAY_TIMEOUT_MS = Number.parseInt( + process.env.RELAY_TIMEOUT_MS ?? "30000", + 10, +); +const SERVER_START_TIME = Date.now(); +const RELAY_VERSION = "1.0.0"; + +// ─── Middleware instances (singletons — persist across warm invocations) ───────── + +const rateLimiter = createRateLimiter({ + maxRequests: Number.parseInt(process.env.RATE_LIMIT_MAX ?? "100", 10), + windowMs: Number.parseInt( + process.env.RATE_LIMIT_WINDOW_MS ?? "60000", + 10, + ), +}); + +// ─── Helpers ───────────────────────────────────────────────────────────────────── + +/** + * Get the client IP from the request headers. + * + * Vercel populates `x-forwarded-for` and/or `cf-connecting-ip` automatically. + * Unlike the standalone server, we do NOT call `server.requestIP()` since + * that Bun API is not available in Vercel Functions. + */ +function getClientIP(req: Request): string { + const forwarded = req.headers.get("x-forwarded-for"); + if (forwarded) { + const first = forwarded.split(",")[0]?.trim(); + if (first) return first; + } + + const cfIp = req.headers.get("cf-connecting-ip"); + if (cfIp) return cfIp; + + return "unknown"; +} + +// ─── Route 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", + "Access-Control-Allow-Origin": "*", + }, + }, + ); +} + +function handleDocs(): Response { + const html = ` + + + + + Edge Proxy Relay — Docs + + + +
+

Edge Proxy Relay

+

Forward HTTP 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: /)
+ +

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
+ +

Note: WebSocket relay is not available on this deployment.

+
+ +`; + + return new Response(html, { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }, + }); +} + +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", + }, + }); +} + +// ─── Relay Logic ──────────────────────────────────────────────────────────────── + +async function handleRelay(req: Request): Promise { + const startTime = performance.now(); + const method = req.method; + const clientIP = getClientIP(req); + const requestUrl = req.url; + + // ── 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 = rateLimiter.check(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", + "Access-Control-Allow-Origin": "*", + "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", + }); + } + + // ── Build the upstream request ────────────────────────────────── + const filteredHeaders = filterRequestHeaders(req.headers); + const fetchOptions = buildRelayRequest( + req, + filteredHeaders, + RELAY_TIMEOUT_MS, + ); + + const targetUrlString = targetUrl.toString(); + + // ── Execute upstream fetch ────────────────────────────────────── + let response: Response; + try { + response = await fetch(targetUrlString, fetchOptions); + } catch (err) { + const classified = classifyFetchError(err); + logRelayEvent({ + method, + url: requestUrl, + status: classified.status, + durationMs: Math.round(performance.now() - startTime), + error: classified.message, + targetUrl: targetUrlString, + ip: clientIP, + }); + return createErrorResponse(classified); + } + + // ── Build relay response ──────────────────────────────────────── + const relayedResponse = createRelayResponse(response); + + logRelayEvent({ + method, + url: requestUrl, + status: relayedResponse.status, + durationMs: Math.round(performance.now() - startTime), + targetUrl: targetUrlString, + ip: clientIP, + }); + + return relayedResponse; +} + +// ─── Exported Vercel Function Handler ─────────────────────────────────────────── + +/** + * Vercel serverless function handler. + * + * Invoked per-request by Vercel's infrastructure. Handles routing, + * middleware, and relay logic — same semantics as the standalone + * Bun.serve() server, minus WebSocket support. + */ +export default async function handler(req: Request): Promise { + const url = new URL(req.url); + + // Static routes + if (url.pathname === "/health") return handleHealth(); + if (url.pathname === "/docs") return handleDocs(); + if (url.pathname === "/" && req.method === "GET") return handleIndex(); + + // WebSocket upgrade — not supported in Vercel Functions + if ( + req.method === "GET" && + req.headers.get("upgrade")?.toLowerCase() === "websocket" + ) { + 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", + "Access-Control-Allow-Origin": "*", + }, + }, + ); + } + + // Generic HTTP relay + return handleRelay(req); +} diff --git a/vercel.json b/vercel.json index 7bd0ee2..b7fd7ec 100644 --- a/vercel.json +++ b/vercel.json @@ -1,9 +1,10 @@ { - "buildCommand": "bun install", - "outputDirectory": "dist", "functions": { - "src/index.ts": { - "runtime": "bun@1" + "api/relay.ts": { + "runtime": "@vercel/node@5" } - } + }, + "rewrites": [ + { "source": "/(.*)", "destination": "/api/relay" } + ] }