feat(proxy-pools): add support for deno deploy relays and fix layout overflow issues in proxy pools dashboard (#1437)
Co-authored-by: TD <tho.din@inno.ai.vn>
This commit is contained in:
@@ -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() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
||||
<Button size="sm" variant="secondary" icon="cloud" onClick={openCloudflareModal}>
|
||||
Cloudflare Relay
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="cloud_upload" onClick={openVercelModal}>
|
||||
Vercel Relay
|
||||
</Button>
|
||||
<div className="relative" ref={relayMenuRef}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="rocket_launch"
|
||||
onClick={() => setShowRelayMenu(!showRelayMenu)}
|
||||
>
|
||||
Deploy Relay
|
||||
<span className="material-symbols-outlined ml-1 text-[18px]">
|
||||
{showRelayMenu ? "expand_less" : "expand_more"}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{showRelayMenu && (
|
||||
<div className="absolute left-0 top-full z-50 mt-1 w-48 rounded-xl border border-black/10 bg-white p-1 shadow-xl dark:border-white/10 dark:bg-zinc-900 sm:left-auto sm:right-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
openCloudflareModal();
|
||||
setShowRelayMenu(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] text-orange-500">cloud</span>
|
||||
Cloudflare Relay
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
openVercelModal();
|
||||
setShowRelayMenu(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] text-blue-500">cloud_upload</span>
|
||||
Vercel Relay
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
openDenoModal();
|
||||
setShowRelayMenu(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-text-main transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[20px] text-green-500">terminal</span>
|
||||
Deno Relay
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="secondary" icon="upload" onClick={openBatchImportModal}>
|
||||
Batch Import
|
||||
</Button>
|
||||
@@ -829,6 +923,70 @@ export default function ProxyPoolsPage() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showDenoModal}
|
||||
title="Deploy Deno Relay"
|
||||
onClose={closeDenoModal}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="rounded-lg bg-black/5 dark:bg-white/5 border border-black/10 dark:border-white/10 p-3 flex flex-col gap-1.5">
|
||||
<p className="text-sm text-text-main font-medium">What is Deno Relay?</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
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.
|
||||
</p>
|
||||
<ul className="text-xs text-text-muted list-disc pl-4 space-y-0.5">
|
||||
<li>Deno Deploy v2 runs on a high-performance global edge network</li>
|
||||
<li>Free tier: 1M requests & 100GiB outbound traffic per month</li>
|
||||
<li>No per-request CPU time limits (unlike Vercel/Cloudflare)</li>
|
||||
<li>Support up to 20 active apps & 50 custom domains</li>
|
||||
<li>Deploy multiple relays for maximum IP diversity</li>
|
||||
</ul>
|
||||
<div className="mt-2 pt-2 border-t border-black/10 dark:border-white/10 text-xs text-text-muted">
|
||||
<p className="font-medium text-text-main mb-1">How to generate API token:</p>
|
||||
<ol className="list-decimal pl-4 space-y-0.5">
|
||||
<li>Go to <b>console.deno.com</b></li>
|
||||
<li>Select your <b>Organization</b> → <b>Settings</b> → <b>Organization Tokens</b></li>
|
||||
<li>Create a <b>Organization Token</b> (prefix <b>ddo_</b>)</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Deno Deploy API Token"
|
||||
value={denoForm.denoToken}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Input
|
||||
label="Organization Domain"
|
||||
value={denoForm.orgDomain}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<Input
|
||||
label="App Name"
|
||||
value={denoForm.projectName}
|
||||
onChange={(e) => setDenoForm((prev) => ({ ...prev, projectName: e.target.value }))}
|
||||
placeholder="deno-relay"
|
||||
hint="Unique app name. Leave empty for auto-generated name."
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleDenoDeploy}
|
||||
disabled={!denoForm.denoToken.trim() || !denoForm.orgDomain.trim() || deploying}
|
||||
>
|
||||
{deploying ? "Deploying..." : "Deploy Relay"}
|
||||
</Button>
|
||||
<Button fullWidth variant="ghost" onClick={closeDenoModal} disabled={deploying}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={showFormModal}
|
||||
title={editingProxyPool ? "Edit Proxy Pool" : "Add Proxy Pool"}
|
||||
|
||||
@@ -43,7 +43,7 @@ export async function POST(request, { params }) {
|
||||
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const result = proxyPool.type === "vercel" || proxyPool.type === "cloudflare"
|
||||
const result = proxyPool.type === "vercel" || proxyPool.type === "cloudflare" || proxyPool.type === "deno"
|
||||
? await testVercelRelay(proxyPool.proxyUrl)
|
||||
: await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
|
||||
const now = new Date().toISOString();
|
||||
|
||||
175
src/app/api/proxy-pools/deno-deploy/route.js
Normal file
175
src/app/api/proxy-pools/deno-deploy/route.js
Normal file
@@ -0,0 +1,175 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createProxyPool } from "@/models";
|
||||
|
||||
const DENO_V2_API = "https://api.deno.com/v2";
|
||||
|
||||
const DENO_RELAY_CODE = `Deno.serve(async (request) => {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user