From 57b3e94e40a9097b9fc87fc8a5e6dba33a5f5419 Mon Sep 17 00:00:00 2001 From: "@TD" Date: Tue, 26 May 2026 11:21:49 +0700 Subject: [PATCH] feat(proxy-pools): add support for deno deploy relays and fix layout overflow issues in proxy pools dashboard (#1437) Co-authored-by: TD --- .../(dashboard)/dashboard/proxy-pools/page.js | 172 ++++++++++++++++- src/app/api/proxy-pools/[id]/test/route.js | 2 +- src/app/api/proxy-pools/deno-deploy/route.js | 175 ++++++++++++++++++ src/app/api/proxy-pools/route.js | 2 +- src/lib/network/connectionProxy.js | 2 +- 5 files changed, 343 insertions(+), 10 deletions(-) create mode 100644 src/app/api/proxy-pools/deno-deploy/route.js diff --git a/src/app/(dashboard)/dashboard/proxy-pools/page.js b/src/app/(dashboard)/dashboard/proxy-pools/page.js index 6c6fc4b1..fcfa986d 100644 --- a/src/app/(dashboard)/dashboard/proxy-pools/page.js +++ b/src/app/(dashboard)/dashboard/proxy-pools/page.js @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, useRef } from "react"; import { Badge, Button, Card, CardSkeleton, Input, Modal, Toggle, ConfirmModal } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; @@ -34,11 +34,14 @@ export default function ProxyPoolsPage() { const [showBatchImportModal, setShowBatchImportModal] = useState(false); const [showVercelModal, setShowVercelModal] = useState(false); const [showCloudflareModal, setShowCloudflareModal] = useState(false); + const [showDenoModal, setShowDenoModal] = useState(false); + const [showRelayMenu, setShowRelayMenu] = useState(false); const [editingProxyPool, setEditingProxyPool] = useState(null); const [formData, setFormData] = useState(normalizeFormData()); const [batchImportText, setBatchImportText] = useState(""); const [vercelForm, setVercelForm] = useState({ vercelToken: "", projectName: "vercel-relay" }); const [cloudflareForm, setCloudflareForm] = useState({ accountId: "", apiToken: "", projectName: "cloudflare-relay" }); + const [denoForm, setDenoForm] = useState({ denoToken: "", orgDomain: "", projectName: "" }); const [saving, setSaving] = useState(false); const [importing, setImporting] = useState(false); const [deploying, setDeploying] = useState(false); @@ -48,8 +51,21 @@ export default function ProxyPoolsPage() { const [healthProgress, setHealthProgress] = useState({ current: 0, total: 0 }); const [bulkBusy, setBulkBusy] = useState(false); const [confirmState, setConfirmState] = useState(null); + const relayMenuRef = useRef(null); const notify = useNotificationStore(); + useEffect(() => { + const handleClickOutside = (e) => { + if (relayMenuRef.current && !relayMenuRef.current.contains(e.target)) { + setShowRelayMenu(false); + } + }; + if (showRelayMenu) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [showRelayMenu]); + const fetchProxyPools = useCallback(async () => { try { const res = await fetch("/api/proxy-pools?includeUsage=true", { cache: "no-store" }); @@ -346,6 +362,16 @@ export default function ProxyPoolsPage() { setShowCloudflareModal(false); }; + const openDenoModal = () => { + setDenoForm({ denoToken: "", orgDomain: "", projectName: "" }); + setShowDenoModal(true); + }; + + const closeDenoModal = () => { + if (deploying) return; + setShowDenoModal(false); + }; + const handleVercelDeploy = async () => { if (!vercelForm.vercelToken.trim()) return; setDeploying(true); @@ -396,6 +422,31 @@ export default function ProxyPoolsPage() { } }; + const handleDenoDeploy = async () => { + if (!denoForm.denoToken.trim()) return; + setDeploying(true); + try { + const res = await fetch("/api/proxy-pools/deno-deploy", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(denoForm), + }); + const data = await res.json(); + if (res.ok) { + await fetchProxyPools(); + closeDenoModal(); + notify.success(`Deployed: ${data.deployUrl}`); + } else { + notify.error(data.error || "Deploy failed"); + } + } catch (error) { + console.log("Error deploying Deno relay:", error); + notify.error("Deploy failed"); + } finally { + setDeploying(false); + } + }; + const parseProxyLine = (line) => { const trimmed = line.trim(); if (!trimmed) return null; @@ -532,12 +583,55 @@ export default function ProxyPoolsPage() {
- - +
+ + + {showRelayMenu && ( +
+ + + +
+ )} +
+ @@ -829,6 +923,70 @@ export default function ProxyPoolsPage() {
+ +
+
+

What is Deno Relay?

+

+ Deploys a relay worker to Deno Deploy's global edge network. All AI provider requests are forwarded through Deno's edge, masking your real IP. +

+
    +
  • Deno Deploy v2 runs on a high-performance global edge network
  • +
  • Free tier: 1M requests & 100GiB outbound traffic per month
  • +
  • No per-request CPU time limits (unlike Vercel/Cloudflare)
  • +
  • Support up to 20 active apps & 50 custom domains
  • +
  • Deploy multiple relays for maximum IP diversity
  • +
+
+

How to generate API token:

+
    +
  1. Go to console.deno.com
  2. +
  3. Select your OrganizationSettingsOrganization Tokens
  4. +
  5. Create a Organization Token (prefix ddo_)
  6. +
+
+
+ setDenoForm((prev) => ({ ...prev, denoToken: e.target.value }))} + placeholder="ddo_xxxxxxxxxxxxxxxx" + hint={<>Token is used once for deployment, not stored. Found in Organization Settings.} + type="password" + /> + setDenoForm((prev) => ({ ...prev, orgDomain: e.target.value }))} + placeholder="your-org.deno.net" + hint="Organization's default domain. Your relay URL will be in the format: https://my-relay.your-org.deno.net" + /> + setDenoForm((prev) => ({ ...prev, projectName: e.target.value }))} + placeholder="deno-relay" + hint="Unique app name. Leave empty for auto-generated name." + /> +
+ + +
+
+
+ { + const target = request.headers.get("x-relay-target"); + const relayPath = request.headers.get("x-relay-path") || "/"; + + if (!target) { + return new Response(JSON.stringify({ error: "Missing x-relay-target header" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + + const targetUrl = target.replace(/\\/$/, "") + relayPath; + const newHeaders = new Headers(request.headers); + newHeaders.delete("x-relay-target"); + newHeaders.delete("x-relay-path"); + newHeaders.delete("host"); + + const init = { + method: request.method, + headers: newHeaders, + }; + + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + init.duplex = "half"; + } + + try { + const response = await fetch(targetUrl, init); + return new Response(response.body, { + status: response.status, + headers: response.headers, + }); + } catch (error) { + return new Response(JSON.stringify({ error: error.message }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + } +});`; + +export async function POST(request) { + try { + const body = await request.json(); + const denoToken = body.denoToken?.trim(); + const orgDomain = body.orgDomain?.trim(); + const projectName = body.projectName?.trim() || `relay-${Date.now().toString(36)}`; + + if (!orgDomain) { + return NextResponse.json({ error: "Organization domain is required" }, { status: 400 }); + } + + if (!denoToken) { + return NextResponse.json({ error: "Deno Deploy API token is required" }, { status: 400 }); + } + + const headers = { + Authorization: `Bearer ${denoToken}`, + "Content-Type": "application/json", + }; + + const createAppRes = await fetch(`${DENO_V2_API}/apps`, { + method: "POST", + headers, + body: JSON.stringify({ + slug: projectName, + labels: { "custom.kind": "9router-relay" }, + config: { + install: "deno install", + runtime: { + type: "dynamic", + entrypoint: "main.ts", + }, + }, + }), + }); + + if (!createAppRes.ok) { + const text = await createAppRes.text().catch(() => ""); + if (createAppRes.status === 409) { + return NextResponse.json( + { error: `App "${projectName}" already exists. Choose a different name.` }, + { status: 409 } + ); + } + return NextResponse.json( + { error: `Failed to create app (${createAppRes.status}): ${text}` }, + { status: createAppRes.status } + ); + } + + const app = await createAppRes.json(); + + const deployRes = await fetch(`${DENO_V2_API}/apps/${app.id}/deploy`, { + method: "POST", + headers, + body: JSON.stringify({ + assets: { + "main.ts": { + kind: "file", + content: DENO_RELAY_CODE, + encoding: "utf-8", + }, + }, + }), + }); + + if (!deployRes.ok) { + const text = await deployRes.text().catch(() => ""); + console.error("Deno Deploy error:", deployRes.status, text); + await fetch(`${DENO_V2_API}/apps/${app.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${denoToken}` }, + }).catch(() => {}); + return NextResponse.json( + { error: `Deploy failed (${deployRes.status}): ${text}` }, + { status: deployRes.status } + ); + } + + const revision = await deployRes.json(); + const revisionId = revision.id; + + let status = revision.status; + let attempts = 0; + const maxAttempts = 30; // 30 * 2s = 60s max + while (status === "queued" || status === "building") { + if (attempts >= maxAttempts) { + throw new Error("Deploy timed out after 60 seconds"); + } + await new Promise((resolve) => setTimeout(resolve, 2000)); + const statusRes = await fetch(`${DENO_V2_API}/revisions/${revisionId}`, { + headers: { Authorization: `Bearer ${denoToken}` }, + }); + if (!statusRes.ok) break; + const statusData = await statusRes.json(); + status = statusData.status; + attempts++; + } + + if (status !== "succeeded") { + await fetch(`${DENO_V2_API}/apps/${app.id}`, { + method: "DELETE", + headers: { Authorization: `Bearer ${denoToken}` }, + }).catch(() => {}); + return NextResponse.json( + { error: `Deploy failed with status: ${status}` }, + { status: 500 } + ); + } + + const orgSlug = orgDomain.split(".")[0]; + const deployUrl = `https://${projectName}.${orgSlug}.deno.net`; + console.log("Deno deployUrl:", deployUrl); + + const proxyPool = await createProxyPool({ + name: projectName, + proxyUrl: deployUrl, + type: "deno", + noProxy: "", + isActive: true, + strictProxy: false, + }); + + return NextResponse.json({ proxyPool, deployUrl }, { status: 201 }); + } catch (error) { + console.log("Error deploying Deno Deploy relay:", error); + return NextResponse.json({ error: error.message || "Deploy failed" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/proxy-pools/route.js b/src/app/api/proxy-pools/route.js index ce119f26..3dcfa06f 100644 --- a/src/app/api/proxy-pools/route.js +++ b/src/app/api/proxy-pools/route.js @@ -7,7 +7,7 @@ function toBoolean(value) { return undefined; } -const VALID_PROXY_TYPES = ["http", "vercel", "cloudflare"]; +const VALID_PROXY_TYPES = ["http", "vercel", "cloudflare", "deno"]; function normalizeProxyPoolInput(body = {}) { const name = typeof body?.name === "string" ? body.name.trim() : ""; diff --git a/src/lib/network/connectionProxy.js b/src/lib/network/connectionProxy.js index ca590819..71c5e008 100644 --- a/src/lib/network/connectionProxy.js +++ b/src/lib/network/connectionProxy.js @@ -71,7 +71,7 @@ export async function resolveConnectionProxyConfig( * Vercel/Cloudflare relay proxies use base URL rewriting * instead of HTTP_PROXY environment variables. */ - if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare") { + if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno") { return { source: proxyPool.type,