diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 3506dc1..74ac393 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -13,3 +13,9 @@ jobs:
- run: bun install
- run: bun run build
- run: bun test
+
+ - name: Deploy to Cloudflare Workers
+ uses: cloudflare/wrangler-action@v3
+ with:
+ apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
diff --git a/src/worker.ts b/src/worker.ts
new file mode 100644
index 0000000..79cf4e7
--- /dev/null
+++ b/src/worker.ts
@@ -0,0 +1,390 @@
+/**
+ * Cloudflare Workers-compatible relay handler.
+ *
+ * Uses `env` bindings for configuration instead of `process.env`.
+ * Exports `{ fetch }` as required by the Cloudflare Workers runtime.
+ *
+ * Reuses the same relay logic from `src/lib/` and `src/middleware/` as
+ * the standalone Bun.serve() server, but:
+ * - Uses `env` for configuration (Workers don't have process.env)
+ * - Does NOT support WebSocket upgrades (Workers can proxy WS but
+ * this handler only handles HTTP relay)
+ * - Rate limiter is per-isolate (resets on cold start)
+ */
+
+import {
+ normalizeTargetUrl,
+ isAllowedTarget,
+ filterRequestHeaders,
+ buildRelayRequest,
+ createRelayResponse,
+ classifyFetchError,
+ createErrorResponse,
+ createCorsPreflightResponse,
+} from "./lib/relay-utils";
+
+import { checkBodySize } from "./middleware/body-limiter";
+import { createRateLimiter } from "./middleware/rate-limiter";
+import { logRelayEvent } from "./middleware/logger";
+
+// ─── Types ───────────────────────────────────────────────────────────────────────
+
+export interface Env {
+ /** Upstream fetch timeout in ms (default: 30000) */
+ RELAY_TIMEOUT_MS?: string;
+ /** Max requests per sliding window (default: 100) */
+ RATE_LIMIT_MAX?: string;
+ /** Sliding window duration in ms (default: 60000) */
+ RATE_LIMIT_WINDOW_MS?: string;
+ /** Server listen port (unused on Workers, here for local dev compatibility) */
+ PORT?: string;
+}
+
+// ─── Helpers ─────────────────────────────────────────────────────────────────────
+
+function getNumericEnv(
+ env: Env,
+ key: keyof Env,
+ fallback: number,
+): number {
+ const val = env[key] ?? (typeof process !== "undefined" ? process.env[key as string] : undefined);
+ return Number.parseInt(val ?? String(fallback), 10);
+}
+
+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 ──────────────────────────────────────────────────────────────
+
+const SERVER_START_TIME = Date.now();
+const RELAY_VERSION = "1.0.0";
+
+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, env: Env): Promise {
+ const startTime = performance.now();
+ const method = req.method;
+ const clientIP = getClientIP(req);
+ const requestUrl = req.url;
+
+ const RELAY_TIMEOUT_MS = getNumericEnv(env, "RELAY_TIMEOUT_MS", 30000);
+
+ // Per-isolate rate limiter (recreated on each cold start)
+ const rateLimiter = createRateLimiter({
+ maxRequests: getNumericEnv(env, "RATE_LIMIT_MAX", 100),
+ windowMs: getNumericEnv(env, "RATE_LIMIT_WINDOW_MS", 60000),
+ });
+
+ // ── 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 Worker Handler ─────────────────────────────────────────────────────
+
+export default {
+ async fetch(req: Request, env: Env): 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 fully supported in this handler
+ if (
+ req.method === "GET" &&
+ req.headers.get("upgrade")?.toLowerCase() === "websocket"
+ ) {
+ return new Response(
+ JSON.stringify({
+ error: true,
+ code: "UNSUPPORTED",
+ message: "WebSocket relay is not available on this deployment",
+ }),
+ {
+ status: 400,
+ headers: {
+ "Content-Type": "application/json",
+ "Access-Control-Allow-Origin": "*",
+ },
+ },
+ );
+ }
+
+ // Generic HTTP relay
+ return handleRelay(req, env);
+ },
+};
diff --git a/wrangler.toml b/wrangler.toml
index 08d93d7..d9081f3 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -1,10 +1,15 @@
name = "edge-proxy-relay"
-main = "src/index.ts"
-compatibility_date = "2024-12-01"
+main = "src/worker.ts"
+compatibility_date = "2025-12-01"
compatibility_flags = ["nodejs_compat"]
-[vars]
-NODE_VERSION = "22"
+# ── Cloudflare account (fill in your Account ID) ─────────────────────────────
+# Find it in the Cloudflare Dashboard → Workers & Pages → right sidebar
+# account_id = ""
+# workers_dev = true
-[env.production]
-name = "edge-proxy-relay-prod"
+# ── Environment Variables ───────────────────────────────────────────────────
+# [vars]
+# RELAY_TIMEOUT_MS = "30000"
+# RATE_LIMIT_MAX = "200"
+# RATE_LIMIT_WINDOW_MS = "60000"