mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
feat: Add Cloudflare Workers proxy deployer and pool integration (#1360)
Co-authored-by: ansh <ansh@example.com>
This commit is contained in:
@@ -33,10 +33,12 @@ export default function ProxyPoolsPage() {
|
|||||||
const [showFormModal, setShowFormModal] = useState(false);
|
const [showFormModal, setShowFormModal] = useState(false);
|
||||||
const [showBatchImportModal, setShowBatchImportModal] = useState(false);
|
const [showBatchImportModal, setShowBatchImportModal] = useState(false);
|
||||||
const [showVercelModal, setShowVercelModal] = useState(false);
|
const [showVercelModal, setShowVercelModal] = useState(false);
|
||||||
|
const [showCloudflareModal, setShowCloudflareModal] = useState(false);
|
||||||
const [editingProxyPool, setEditingProxyPool] = useState(null);
|
const [editingProxyPool, setEditingProxyPool] = useState(null);
|
||||||
const [formData, setFormData] = useState(normalizeFormData());
|
const [formData, setFormData] = useState(normalizeFormData());
|
||||||
const [batchImportText, setBatchImportText] = useState("");
|
const [batchImportText, setBatchImportText] = useState("");
|
||||||
const [vercelForm, setVercelForm] = useState({ vercelToken: "", projectName: "vercel-relay" });
|
const [vercelForm, setVercelForm] = useState({ vercelToken: "", projectName: "vercel-relay" });
|
||||||
|
const [cloudflareForm, setCloudflareForm] = useState({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
@@ -334,6 +336,16 @@ export default function ProxyPoolsPage() {
|
|||||||
setShowVercelModal(false);
|
setShowVercelModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openCloudflareModal = () => {
|
||||||
|
setCloudflareForm({ accountId: "", apiToken: "", projectName: "cloudflare-relay" });
|
||||||
|
setShowCloudflareModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeCloudflareModal = () => {
|
||||||
|
if (deploying) return;
|
||||||
|
setShowCloudflareModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleVercelDeploy = async () => {
|
const handleVercelDeploy = async () => {
|
||||||
if (!vercelForm.vercelToken.trim()) return;
|
if (!vercelForm.vercelToken.trim()) return;
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
@@ -359,6 +371,31 @@ export default function ProxyPoolsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloudflareDeploy = async () => {
|
||||||
|
if (!cloudflareForm.accountId.trim() || !cloudflareForm.apiToken.trim()) return;
|
||||||
|
setDeploying(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/proxy-pools/cloudflare-deploy", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(cloudflareForm),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
await fetchProxyPools();
|
||||||
|
closeCloudflareModal();
|
||||||
|
notify.success(`Deployed: ${data.deployUrl}`);
|
||||||
|
} else {
|
||||||
|
notify.error(data.error || "Deploy failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error deploying Cloudflare relay:", error);
|
||||||
|
notify.error("Deploy failed");
|
||||||
|
} finally {
|
||||||
|
setDeploying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const parseProxyLine = (line) => {
|
const parseProxyLine = (line) => {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
@@ -495,6 +532,9 @@ export default function ProxyPoolsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-2 sm:flex sm:items-center">
|
<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}>
|
<Button size="sm" variant="secondary" icon="cloud_upload" onClick={openVercelModal}>
|
||||||
Vercel Relay
|
Vercel Relay
|
||||||
</Button>
|
</Button>
|
||||||
@@ -588,6 +628,9 @@ export default function ProxyPoolsPage() {
|
|||||||
{pool.type === "vercel" && (
|
{pool.type === "vercel" && (
|
||||||
<Badge variant="default" size="sm">vercel relay</Badge>
|
<Badge variant="default" size="sm">vercel relay</Badge>
|
||||||
)}
|
)}
|
||||||
|
{pool.type === "cloudflare" && (
|
||||||
|
<Badge variant="default" size="sm">cloudflare relay</Badge>
|
||||||
|
)}
|
||||||
<Badge variant="default" size="sm">
|
<Badge variant="default" size="sm">
|
||||||
{pool.boundConnectionCount || 0} bound
|
{pool.boundConnectionCount || 0} bound
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -722,6 +765,70 @@ export default function ProxyPoolsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showCloudflareModal}
|
||||||
|
title="Deploy Cloudflare Relay"
|
||||||
|
onClose={closeCloudflareModal}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg bg-orange-500/5 border border-orange-500/10 p-3 flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm text-text-main font-medium">What is Cloudflare Relay?</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Deploys a Cloudflare Worker as a proxy relay. All AI provider requests will be forwarded through Cloudflare's global edge network.
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs text-text-muted list-disc pl-4 space-y-0.5">
|
||||||
|
<li>High performance global routing and IP masking via Cloudflare Workers</li>
|
||||||
|
<li>Free tier: 100,000 requests per day</li>
|
||||||
|
<li>Requires Cloudflare Account ID and a Workers API Token (Edit Workers permission)</li>
|
||||||
|
</ul>
|
||||||
|
<div className="mt-2 pt-2 border-t border-orange-500/10 text-xs text-text-muted">
|
||||||
|
<p className="font-medium text-text-main mb-1">How to generate your API Token:</p>
|
||||||
|
<ol className="list-decimal pl-4 space-y-0.5">
|
||||||
|
<li>Go to <b>My Profile</b> → <b>API Tokens</b> → <b>Create Token</b></li>
|
||||||
|
<li>Scroll down to <b>Custom Token</b> and click <b>Get started</b></li>
|
||||||
|
<li>Under <b>Permissions</b>: Account | Workers Scripts | Edit</li>
|
||||||
|
<li>Under <b>Account Resources</b>: Include | Account | <i>Your Account Name</i></li>
|
||||||
|
<li>Click <b>Continue to summary</b> → <b>Create Token</b></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Account ID"
|
||||||
|
value={cloudflareForm.accountId}
|
||||||
|
onChange={(e) => setCloudflareForm((prev) => ({ ...prev, accountId: e.target.value }))}
|
||||||
|
placeholder="your-cloudflare-account-id"
|
||||||
|
hint={<>Found on the right side of the Cloudflare dashboard overview page.</>}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="API Token"
|
||||||
|
value={cloudflareForm.apiToken}
|
||||||
|
onChange={(e) => setCloudflareForm((prev) => ({ ...prev, apiToken: e.target.value }))}
|
||||||
|
placeholder="your-cloudflare-api-token"
|
||||||
|
hint={<>Requires "Workers Scripts: Edit" permission. <a href="https://dash.cloudflare.com/profile/api-tokens" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Get token →</a></>}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Worker Name"
|
||||||
|
value={cloudflareForm.projectName}
|
||||||
|
onChange={(e) => setCloudflareForm((prev) => ({ ...prev, projectName: e.target.value }))}
|
||||||
|
placeholder="my-relay"
|
||||||
|
hint="Unique name for your Cloudflare Worker. Leave empty for auto-generated name."
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
onClick={handleCloudflareDeploy}
|
||||||
|
disabled={!cloudflareForm.accountId.trim() || !cloudflareForm.apiToken.trim() || deploying}
|
||||||
|
>
|
||||||
|
{deploying ? "Deploying..." : "Deploy Worker"}
|
||||||
|
</Button>
|
||||||
|
<Button fullWidth variant="ghost" onClick={closeCloudflareModal} disabled={deploying}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showFormModal}
|
isOpen={showFormModal}
|
||||||
title={editingProxyPool ? "Edit Proxy Pool" : "Add Proxy Pool"}
|
title={editingProxyPool ? "Edit Proxy Pool" : "Add Proxy Pool"}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function normalizeProxyPoolUpdate(body = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Object.prototype.hasOwnProperty.call(body, "type")) {
|
if (Object.prototype.hasOwnProperty.call(body, "type")) {
|
||||||
const validTypes = ["http", "vercel"];
|
const validTypes = ["http", "vercel", "cloudflare"];
|
||||||
updates.type = validTypes.includes(body?.type) ? body.type : "http";
|
updates.type = validTypes.includes(body?.type) ? body.type : "http";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export async function POST(request, { params }) {
|
|||||||
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
|
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = proxyPool.type === "vercel"
|
const result = proxyPool.type === "vercel" || proxyPool.type === "cloudflare"
|
||||||
? await testVercelRelay(proxyPool.proxyUrl)
|
? await testVercelRelay(proxyPool.proxyUrl)
|
||||||
: await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
|
: await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createProxyPool } from "@/models";
|
||||||
|
|
||||||
|
// Relay worker source code deployed to Cloudflare
|
||||||
|
const RELAY_WORKER_CODE = `
|
||||||
|
export default {
|
||||||
|
async fetch(request, env, ctx) {
|
||||||
|
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 newRequestInit = {
|
||||||
|
method: request.method,
|
||||||
|
headers: new Headers(request.headers),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||||
|
newRequestInit.body = request.body;
|
||||||
|
newRequestInit.duplex = "half";
|
||||||
|
}
|
||||||
|
|
||||||
|
newRequestInit.headers.delete("x-relay-target");
|
||||||
|
newRequestInit.headers.delete("x-relay-path");
|
||||||
|
newRequestInit.headers.delete("host");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(targetUrl, newRequestInit);
|
||||||
|
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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
`;
|
||||||
|
|
||||||
|
// POST /api/proxy-pools/cloudflare-deploy
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const accountId = body.accountId?.trim();
|
||||||
|
const apiToken = body.apiToken?.trim();
|
||||||
|
const projectName = body.projectName?.trim() || `relay-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
if (!accountId || !apiToken) {
|
||||||
|
return NextResponse.json({ error: "Cloudflare Account ID and API Token are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Upload Worker Script
|
||||||
|
const workerScriptUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/scripts/${projectName}`;
|
||||||
|
|
||||||
|
// Cloudflare requires multipart/form-data for worker script upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("index.js", new Blob([RELAY_WORKER_CODE], { type: "application/javascript+module" }), "index.js");
|
||||||
|
formData.append("metadata", new Blob([JSON.stringify({
|
||||||
|
main_module: "index.js",
|
||||||
|
compatibility_date: "2024-03-20",
|
||||||
|
observability: { enabled: true }
|
||||||
|
})], { type: "application/json" }), "metadata.json");
|
||||||
|
|
||||||
|
const uploadRes = await fetch(workerScriptUrl, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!uploadRes.ok) {
|
||||||
|
const err = await uploadRes.json().catch(() => ({}));
|
||||||
|
console.error("Cloudflare upload error:", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.errors?.[0]?.message || "Failed to upload Worker to Cloudflare" },
|
||||||
|
{ status: uploadRes.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Enable workers.dev subdomain for the script
|
||||||
|
const enableSubdomainRes = await fetch(`${workerScriptUrl}/subdomain`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ enabled: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enableSubdomainRes.ok) {
|
||||||
|
const err = await enableSubdomainRes.json().catch(() => ({}));
|
||||||
|
console.error("Cloudflare subdomain enable error:", err);
|
||||||
|
// We don't fail completely here, just continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get the workers.dev subdomain for the account to construct the final URL
|
||||||
|
let deployUrl = "";
|
||||||
|
const subdomainRes = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/workers/subdomain`, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${apiToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subdomainRes.ok) {
|
||||||
|
const subdomainData = await subdomainRes.json();
|
||||||
|
if (subdomainData.result && subdomainData.result.subdomain) {
|
||||||
|
deployUrl = `https://${projectName}.${subdomainData.result.subdomain}.workers.dev`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deployUrl) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Worker deployed but failed to retrieve workers.dev subdomain. Make sure you have setup a workers.dev subdomain in Cloudflare Dashboard." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create proxy pool entry with type cloudflare
|
||||||
|
const proxyPool = await createProxyPool({
|
||||||
|
name: projectName,
|
||||||
|
proxyUrl: deployUrl,
|
||||||
|
type: "cloudflare",
|
||||||
|
noProxy: "",
|
||||||
|
isActive: true,
|
||||||
|
strictProxy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ proxyPool, deployUrl }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error deploying Cloudflare relay:", error);
|
||||||
|
return NextResponse.json({ error: error.message || "Deploy failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ function toBoolean(value) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const VALID_PROXY_TYPES = ["http", "vercel"];
|
const VALID_PROXY_TYPES = ["http", "vercel", "cloudflare"];
|
||||||
|
|
||||||
function normalizeProxyPoolInput(body = {}) {
|
function normalizeProxyPoolInput(body = {}) {
|
||||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||||
|
|||||||
@@ -68,12 +68,12 @@ export async function resolveConnectionProxyConfig(
|
|||||||
|
|
||||||
if (isValidPool) {
|
if (isValidPool) {
|
||||||
/**
|
/**
|
||||||
* Vercel relay proxies use base URL rewriting
|
* Vercel/Cloudflare relay proxies use base URL rewriting
|
||||||
* instead of HTTP_PROXY environment variables.
|
* instead of HTTP_PROXY environment variables.
|
||||||
*/
|
*/
|
||||||
if (proxyPool.type === "vercel") {
|
if (proxyPool.type === "vercel" || proxyPool.type === "cloudflare") {
|
||||||
return {
|
return {
|
||||||
source: "vercel",
|
source: proxyPool.type,
|
||||||
|
|
||||||
proxyPoolId,
|
proxyPoolId,
|
||||||
proxyPool,
|
proxyPool,
|
||||||
@@ -84,7 +84,7 @@ export async function resolveConnectionProxyConfig(
|
|||||||
|
|
||||||
strictProxy: proxyPool.strictProxy === true,
|
strictProxy: proxyPool.strictProxy === true,
|
||||||
|
|
||||||
vercelRelayUrl: proxyUrl,
|
vercelRelayUrl: proxyUrl, // Still mapped to vercelRelayUrl in the unified payload since they use the exact same header spec
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user