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"
+
+ | Header | Required | Description |
+ x-relay-target | Yes | Base URL of the upstream (http:// or https://) |
+ x-relay-path | No | Path to append (default: /) |
+
+
+ Status Codes
+
+ | Code | Meaning |
+ | 204 | CORS preflight success (OPTIONS) |
+ | 400 | Missing x-relay-target header |
+ | 403 | Target blocked (SSRF protection / not allowed) |
+ | 413 | Request body exceeds size limit |
+ | 429 | Rate limit exceeded |
+ | 502 | Upstream network / DNS error |
+ | 504 | Upstream 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" }
+ ]
}