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:
+
+ - Go to console.deno.com
+ - Select your Organization → Settings → Organization Tokens
+ - Create a Organization Token (prefix ddo_)
+
+
+
+
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,