From 7a4df35371b7422d5717d30e032ecf01470a9287 Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Wed, 10 Jun 2026 21:38:04 +0700 Subject: [PATCH] feat: add Cloudflare Workers deployment support - Create src/worker.ts: Workers-compatible relay handler using env bindings instead of process.env, exports { fetch } for Workers runtime - Update wrangler.toml: point to new src/worker.ts entry point - Update deploy.yml: add wrangler-action deploy step after tests pass - The worker reuses the same relay logic from src/lib/ and src/middleware/ Co-Authored-By: Claude Fable 5 --- .github/workflows/deploy.yml | 6 + src/worker.ts | 390 +++++++++++++++++++++++++++++++++++ wrangler.toml | 17 +- 3 files changed, 407 insertions(+), 6 deletions(-) create mode 100644 src/worker.ts 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"
+ + + + +
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, 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"