Enhance proxy functionality with Vercel relay support
This commit is contained in:
@@ -44,7 +44,7 @@ export const PROVIDER_MODELS = {
|
|||||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||||
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
|
{ id: "qwen3-coder-flash", name: "Qwen3 Coder Flash" },
|
||||||
{ id: "vision-model", name: "Qwen3 Vision Model" },
|
{ id: "vision-model", name: "Qwen3 Vision Model" },
|
||||||
{ id: "coder-model", name: "Qwen3.5 Coder Model" },
|
{ id: "coder-model", name: "Qwen3.6 Coder Model" },
|
||||||
],
|
],
|
||||||
if: [ // iFlow AI
|
if: [ // iFlow AI
|
||||||
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
{ id: "qwen3-coder-plus", name: "Qwen3 Coder Plus" },
|
||||||
|
|||||||
@@ -215,7 +215,7 @@ export class CursorExecutor extends BaseExecutor {
|
|||||||
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
const transformedBody = this.transformRequest(model, body, stream, credentials);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const shouldForceFetch = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true;
|
const shouldForceFetch = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true || !!proxyOptions?.vercelRelayUrl;
|
||||||
const response = (http2 && !shouldForceFetch)
|
const response = (http2 && !shouldForceFetch)
|
||||||
? await this.makeHttp2Request(url, headers, transformedBody, signal)
|
? await this.makeHttp2Request(url, headers, transformedBody, signal)
|
||||||
: await this.makeFetchRequest(url, headers, transformedBody, signal, proxyOptions);
|
: await this.makeFetchRequest(url, headers, transformedBody, signal, proxyOptions);
|
||||||
|
|||||||
@@ -98,9 +98,14 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
|||||||
connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true,
|
connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true,
|
||||||
connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "",
|
connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "",
|
||||||
connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "",
|
connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "",
|
||||||
|
vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) {
|
if (proxyOptions.vercelRelayUrl) {
|
||||||
|
const connectionName = credentials?.connectionName || credentials?.connectionId || "unknown";
|
||||||
|
const poolId = credentials?.providerSpecificData?.connectionProxyPoolId || "none";
|
||||||
|
log?.info?.("PROXY", `${provider.toUpperCase()} | ${model} | conn=${connectionName} | pool=${poolId} | vercel-relay=${proxyOptions.vercelRelayUrl}`);
|
||||||
|
} else if (proxyOptions.connectionProxyEnabled && proxyOptions.connectionProxyUrl) {
|
||||||
let maskedProxyUrl = proxyOptions.connectionProxyUrl;
|
let maskedProxyUrl = proxyOptions.connectionProxyUrl;
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(proxyOptions.connectionProxyUrl);
|
const parsed = new URL(proxyOptions.connectionProxyUrl);
|
||||||
|
|||||||
@@ -198,6 +198,18 @@ async function createBypassRequest(parsedUrl, realIP, options) {
|
|||||||
export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
||||||
const targetUrl = typeof url === "string" ? url : url.toString();
|
const targetUrl = typeof url === "string" ? url : url.toString();
|
||||||
|
|
||||||
|
// Vercel relay: forward request via relay headers
|
||||||
|
const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl);
|
||||||
|
if (vercelRelayUrl) {
|
||||||
|
const parsed = new URL(targetUrl);
|
||||||
|
const relayHeaders = {
|
||||||
|
...options.headers,
|
||||||
|
"x-relay-target": `${parsed.protocol}//${parsed.host}`,
|
||||||
|
"x-relay-path": `${parsed.pathname}${parsed.search}`,
|
||||||
|
};
|
||||||
|
return originalFetch(vercelRelayUrl, { ...options, headers: relayHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions);
|
const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions);
|
||||||
const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl));
|
const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl));
|
||||||
const proxyUrl = connectionProxyUrl || envProxyUrl;
|
const proxyUrl = connectionProxyUrl || envProxyUrl;
|
||||||
|
|||||||
@@ -1019,7 +1019,10 @@ function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCusto
|
|||||||
>
|
>
|
||||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||||
</span>
|
</span>
|
||||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
<div className="flex flex-col gap-1">
|
||||||
|
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||||
|
{model.name && <span className="text-[9px] text-text-muted/70 italic pl-1">{model.name}</span>}
|
||||||
|
</div>
|
||||||
{onTest && (
|
{onTest && (
|
||||||
<div className="relative group/btn">
|
<div className="relative group/btn">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export function ModelRow({ model, fullModel, copied, onCopy, testStatus, isCusto
|
|||||||
<span className="material-symbols-outlined text-base" style={iconColor ? { color: iconColor } : undefined}>
|
<span className="material-symbols-outlined text-base" style={iconColor ? { color: iconColor } : undefined}>
|
||||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||||
</span>
|
</span>
|
||||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
<div className="flex flex-col gap-1">
|
||||||
|
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||||
|
{model.name && <span className="text-[9px] text-text-muted/70 italic pl-1">{model.name}</span>}
|
||||||
|
</div>
|
||||||
{onTest && (
|
{onTest && (
|
||||||
<div className="relative group/btn">
|
<div className="relative group/btn">
|
||||||
<button onClick={onTest} disabled={isTesting} className={`p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-opacity ${isTesting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
|
<button onClick={onTest} disabled={isTesting} className={`p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-opacity ${isTesting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}>
|
||||||
|
|||||||
@@ -32,11 +32,14 @@ export default function ProxyPoolsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
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 [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 [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 [testingId, setTestingId] = useState(null);
|
const [testingId, setTestingId] = useState(null);
|
||||||
const notify = useNotificationStore();
|
const notify = useNotificationStore();
|
||||||
|
|
||||||
@@ -169,6 +172,41 @@ export default function ProxyPoolsPage() {
|
|||||||
setShowBatchImportModal(false);
|
setShowBatchImportModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openVercelModal = () => {
|
||||||
|
setVercelForm({ vercelToken: "", projectName: "vercel-relay" });
|
||||||
|
setShowVercelModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeVercelModal = () => {
|
||||||
|
if (deploying) return;
|
||||||
|
setShowVercelModal(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVercelDeploy = async () => {
|
||||||
|
if (!vercelForm.vercelToken.trim()) return;
|
||||||
|
setDeploying(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/proxy-pools/vercel-deploy", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(vercelForm),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
await fetchProxyPools();
|
||||||
|
closeVercelModal();
|
||||||
|
notify.success(`Deployed: ${data.deployUrl}`);
|
||||||
|
} else {
|
||||||
|
notify.error(data.error || "Deploy failed");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error deploying Vercel 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;
|
||||||
@@ -305,8 +343,11 @@ export default function ProxyPoolsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" icon="cloud_upload" onClick={openVercelModal}>
|
||||||
|
Vercel Relay
|
||||||
|
</Button>
|
||||||
<Button variant="secondary" icon="upload" onClick={openBatchImportModal}>
|
<Button variant="secondary" icon="upload" onClick={openBatchImportModal}>
|
||||||
Batch Import Proxies
|
Batch Import
|
||||||
</Button>
|
</Button>
|
||||||
<Button icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
|
<Button icon="add" onClick={openCreateModal}>Add Proxy Pool</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,6 +382,9 @@ export default function ProxyPoolsPage() {
|
|||||||
<Badge variant={pool.isActive ? "success" : "default"} size="sm">
|
<Badge variant={pool.isActive ? "success" : "default"} size="sm">
|
||||||
{pool.isActive ? "active" : "inactive"}
|
{pool.isActive ? "active" : "inactive"}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{pool.type === "vercel" && (
|
||||||
|
<Badge variant="default" size="sm">vercel relay</Badge>
|
||||||
|
)}
|
||||||
<Badge variant="default" size="sm">
|
<Badge variant="default" size="sm">
|
||||||
{pool.boundConnectionCount || 0} bound
|
{pool.boundConnectionCount || 0} bound
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -420,6 +464,54 @@ export default function ProxyPoolsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={showVercelModal}
|
||||||
|
title="Deploy Vercel Relay"
|
||||||
|
onClose={closeVercelModal}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="rounded-lg bg-blue-500/5 border border-blue-500/10 p-3 flex flex-col gap-1.5">
|
||||||
|
<p className="text-sm text-text-main font-medium">What is Vercel Relay?</p>
|
||||||
|
<p className="text-xs text-text-muted">
|
||||||
|
Deploys an edge relay function to Vercel. All AI provider requests will be forwarded through Vercel's edge network, masking your real IP from providers.
|
||||||
|
</p>
|
||||||
|
<ul className="text-xs text-text-muted list-disc pl-4 space-y-0.5">
|
||||||
|
<li>Your IP is replaced by Vercel's dynamic edge IPs (hundreds of IPs across 20+ global regions)</li>
|
||||||
|
<li>Vercel serves millions of apps — providers can't block Vercel IPs without affecting legitimate traffic</li>
|
||||||
|
<li>Free tier: 100GB bandwidth/month, 500K edge invocations</li>
|
||||||
|
<li>Deploy multiple relays on different accounts for more IP diversity</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Vercel API Token"
|
||||||
|
value={vercelForm.vercelToken}
|
||||||
|
onChange={(e) => setVercelForm((prev) => ({ ...prev, vercelToken: e.target.value }))}
|
||||||
|
placeholder="your-vercel-api-token"
|
||||||
|
hint={<>Token is used once for deployment and not stored. <a href="https://vercel.com/account/tokens" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">Get token →</a></>}
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Project Name"
|
||||||
|
value={vercelForm.projectName}
|
||||||
|
onChange={(e) => setVercelForm((prev) => ({ ...prev, projectName: e.target.value }))}
|
||||||
|
placeholder="my-relay"
|
||||||
|
hint="Unique name for your Vercel project. Leave empty for auto-generated name."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
fullWidth
|
||||||
|
onClick={handleVercelDeploy}
|
||||||
|
disabled={!vercelForm.vercelToken.trim() || deploying}
|
||||||
|
>
|
||||||
|
{deploying ? "Deploying... (may take ~1 min)" : "Deploy"}
|
||||||
|
</Button>
|
||||||
|
<Button fullWidth variant="ghost" onClick={closeVercelModal} 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"}
|
||||||
|
|||||||
@@ -314,6 +314,14 @@ async function testOAuthConnection(connection, effectiveProxy = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchWithConnectionProxy(url, options = {}, effectiveProxy = null) {
|
async function fetchWithConnectionProxy(url, options = {}, effectiveProxy = null) {
|
||||||
|
// Vercel relay: forward via relay URL
|
||||||
|
if (effectiveProxy?.vercelRelayUrl) {
|
||||||
|
const { proxyAwareFetch } = await import("open-sse/utils/proxyFetch.js");
|
||||||
|
return proxyAwareFetch(url, options, {
|
||||||
|
vercelRelayUrl: effectiveProxy.vercelRelayUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!effectiveProxy?.connectionProxyEnabled || !effectiveProxy?.connectionProxyUrl) {
|
if (!effectiveProxy?.connectionProxyEnabled || !effectiveProxy?.connectionProxyUrl) {
|
||||||
return fetch(url, options);
|
return fetch(url, options);
|
||||||
}
|
}
|
||||||
@@ -524,7 +532,7 @@ export async function testSingleConnection(id) {
|
|||||||
|
|
||||||
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
const effectiveProxy = await resolveConnectionProxyConfig(connection.providerSpecificData || {});
|
||||||
|
|
||||||
if (effectiveProxy.connectionProxyEnabled && effectiveProxy.connectionProxyUrl) {
|
if (effectiveProxy.connectionProxyEnabled && effectiveProxy.connectionProxyUrl && !effectiveProxy.vercelRelayUrl) {
|
||||||
const proxyResult = await testProxyUrl({ proxyUrl: effectiveProxy.connectionProxyUrl });
|
const proxyResult = await testProxyUrl({ proxyUrl: effectiveProxy.connectionProxyUrl });
|
||||||
if (!proxyResult.ok) {
|
if (!proxyResult.ok) {
|
||||||
const proxyError = proxyResult.error || `Proxy test failed with status ${proxyResult.status}`;
|
const proxyError = proxyResult.error || `Proxy test failed with status ${proxyResult.status}`;
|
||||||
|
|||||||
@@ -37,6 +37,11 @@ function normalizeProxyPoolUpdate(body = {}) {
|
|||||||
updates.strictProxy = body?.strictProxy === true;
|
updates.strictProxy = body?.strictProxy === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(body, "type")) {
|
||||||
|
const validTypes = ["http", "vercel"];
|
||||||
|
updates.type = validTypes.includes(body?.type) ? body.type : "http";
|
||||||
|
}
|
||||||
|
|
||||||
return { updates };
|
return { updates };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,37 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getProxyPoolById, updateProxyPool } from "@/models";
|
import { getProxyPoolById, updateProxyPool } from "@/models";
|
||||||
import { testProxyUrl } from "@/lib/network/proxyTest";
|
import { testProxyUrl } from "@/lib/network/proxyTest";
|
||||||
|
import { fetch as undiciFetch } from "undici";
|
||||||
|
|
||||||
|
async function testVercelRelay(relayUrl, timeoutMs = 10000) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await undiciFetch(relayUrl, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"x-relay-target": "https://httpbin.org",
|
||||||
|
"x-relay-path": "/get",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
ok: res.ok,
|
||||||
|
status: res.status,
|
||||||
|
statusText: res.statusText,
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
error: err?.name === "AbortError" ? "Relay test timed out" : (err?.message || String(err)),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/proxy-pools/[id]/test - Test proxy pool entry
|
// POST /api/proxy-pools/[id]/test - Test proxy pool entry
|
||||||
export async function POST(request, { params }) {
|
export async function POST(request, { params }) {
|
||||||
@@ -12,7 +43,9 @@ 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 = await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
|
const result = proxyPool.type === "vercel"
|
||||||
|
? await testVercelRelay(proxyPool.proxyUrl)
|
||||||
|
: await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
await updateProxyPool(id, {
|
await updateProxyPool(id, {
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ function toBoolean(value) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VALID_PROXY_TYPES = ["http", "vercel"];
|
||||||
|
|
||||||
function normalizeProxyPoolInput(body = {}) {
|
function normalizeProxyPoolInput(body = {}) {
|
||||||
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
const name = typeof body?.name === "string" ? body.name.trim() : "";
|
||||||
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
|
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
|
||||||
const noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
|
const noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
|
||||||
const isActive = body?.isActive === undefined ? true : body.isActive === true;
|
const isActive = body?.isActive === undefined ? true : body.isActive === true;
|
||||||
const strictProxy = body?.strictProxy === true;
|
const strictProxy = body?.strictProxy === true;
|
||||||
|
const type = VALID_PROXY_TYPES.includes(body?.type) ? body.type : "http";
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return { error: "Name is required" };
|
return { error: "Name is required" };
|
||||||
@@ -22,7 +25,7 @@ function normalizeProxyPoolInput(body = {}) {
|
|||||||
return { error: "Proxy URL is required" };
|
return { error: "Proxy URL is required" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { name, proxyUrl, noProxy, isActive, strictProxy };
|
return { name, proxyUrl, noProxy, isActive, strictProxy, type };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUsageMap(connections = []) {
|
function buildUsageMap(connections = []) {
|
||||||
|
|||||||
142
src/app/api/proxy-pools/vercel-deploy/route.js
Normal file
142
src/app/api/proxy-pools/vercel-deploy/route.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createProxyPool } from "@/models";
|
||||||
|
|
||||||
|
const VERCEL_API = "https://api.vercel.com";
|
||||||
|
|
||||||
|
// Relay function source code deployed to Vercel
|
||||||
|
// Forwards requests to target URL specified in x-relay-target header
|
||||||
|
const RELAY_FUNCTION_CODE = `
|
||||||
|
export const config = { runtime: "edge" };
|
||||||
|
|
||||||
|
export default async function handler(req) {
|
||||||
|
const target = req.headers.get("x-relay-target");
|
||||||
|
const relayPath = req.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 headers = new Headers(req.headers);
|
||||||
|
headers.delete("x-relay-target");
|
||||||
|
headers.delete("x-relay-path");
|
||||||
|
headers.delete("host");
|
||||||
|
|
||||||
|
const response = await fetch(targetUrl, {
|
||||||
|
method: req.method,
|
||||||
|
headers,
|
||||||
|
body: req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
|
||||||
|
duplex: "half",
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
async function pollDeployment(deploymentId, token, maxMs = 120000) {
|
||||||
|
const start = Date.now();
|
||||||
|
while (Date.now() - start < maxMs) {
|
||||||
|
const res = await fetch(`${VERCEL_API}/v13/deployments/${deploymentId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.readyState === "READY") return data;
|
||||||
|
if (data.readyState === "ERROR" || data.readyState === "CANCELED") {
|
||||||
|
throw new Error(`Deployment failed: ${data.readyState}`);
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
}
|
||||||
|
throw new Error("Deployment timed out");
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/proxy-pools/vercel-deploy
|
||||||
|
export async function POST(request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const vercelToken = body.vercelToken;
|
||||||
|
const projectName = body.projectName?.trim() || `relay-${Date.now().toString(36)}`;
|
||||||
|
|
||||||
|
if (!vercelToken) {
|
||||||
|
return NextResponse.json({ error: "Vercel API token is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy relay function to Vercel
|
||||||
|
const deployRes = await fetch(`${VERCEL_API}/v13/deployments`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${vercelToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: projectName,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file: "api/relay.js",
|
||||||
|
data: RELAY_FUNCTION_CODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "package.json",
|
||||||
|
data: JSON.stringify({ name: projectName, version: "1.0.0" }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: "vercel.json",
|
||||||
|
data: JSON.stringify({
|
||||||
|
rewrites: [{ source: "/(.*)", destination: "/api/relay" }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
projectSettings: {
|
||||||
|
framework: null,
|
||||||
|
},
|
||||||
|
target: "production",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!deployRes.ok) {
|
||||||
|
const err = await deployRes.json().catch(() => ({}));
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err.error?.message || "Failed to create Vercel deployment" },
|
||||||
|
{ status: deployRes.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployment = await deployRes.json();
|
||||||
|
const deploymentId = deployment.id || deployment.uid;
|
||||||
|
|
||||||
|
// Disable deployment protection (Vercel Authentication)
|
||||||
|
const projectId = deployment.projectId || projectName;
|
||||||
|
await fetch(`${VERCEL_API}/v9/projects/${projectId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${vercelToken}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ssoProtection: null }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll until deployment is ready
|
||||||
|
const ready = await pollDeployment(deploymentId, vercelToken);
|
||||||
|
const deployUrl = `https://${ready.url}`;
|
||||||
|
|
||||||
|
// Create proxy pool entry with type vercel
|
||||||
|
const proxyPool = await createProxyPool({
|
||||||
|
name: projectName,
|
||||||
|
proxyUrl: deployUrl,
|
||||||
|
type: "vercel",
|
||||||
|
noProxy: "",
|
||||||
|
isActive: true,
|
||||||
|
strictProxy: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ proxyPool, deployUrl }, { status: 201 });
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Error deploying Vercel relay:", error);
|
||||||
|
return NextResponse.json({ error: error.message || "Deploy failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -496,6 +496,7 @@ export async function createProxyPool(data) {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
proxyUrl: data.proxyUrl,
|
proxyUrl: data.proxyUrl,
|
||||||
noProxy: data.noProxy || "",
|
noProxy: data.noProxy || "",
|
||||||
|
type: data.type || "http",
|
||||||
isActive: data.isActive !== undefined ? data.isActive : true,
|
isActive: data.isActive !== undefined ? data.isActive : true,
|
||||||
strictProxy: data.strictProxy === true,
|
strictProxy: data.strictProxy === true,
|
||||||
testStatus: data.testStatus || "unknown",
|
testStatus: data.testStatus || "unknown",
|
||||||
|
|||||||
@@ -28,6 +28,20 @@ export async function resolveConnectionProxyConfig(providerSpecificData = {}) {
|
|||||||
const noProxy = normalizeString(proxyPool?.noProxy);
|
const noProxy = normalizeString(proxyPool?.noProxy);
|
||||||
|
|
||||||
if (proxyPool && proxyPool.isActive === true && proxyUrl) {
|
if (proxyPool && proxyPool.isActive === true && proxyUrl) {
|
||||||
|
// Vercel relay: rewrite base URL instead of using HTTP_PROXY
|
||||||
|
if (proxyPool.type === "vercel") {
|
||||||
|
return {
|
||||||
|
source: "vercel",
|
||||||
|
proxyPoolId,
|
||||||
|
proxyPool,
|
||||||
|
connectionProxyEnabled: false,
|
||||||
|
connectionProxyUrl: "",
|
||||||
|
connectionNoProxy: noProxy,
|
||||||
|
strictProxy: proxyPool.strictProxy === true,
|
||||||
|
vercelRelayUrl: proxyUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
source: "pool",
|
source: "pool",
|
||||||
proxyPoolId,
|
proxyPoolId,
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu
|
|||||||
connectionProxyUrl: resolvedProxy.connectionProxyUrl,
|
connectionProxyUrl: resolvedProxy.connectionProxyUrl,
|
||||||
connectionNoProxy: resolvedProxy.connectionNoProxy,
|
connectionNoProxy: resolvedProxy.connectionNoProxy,
|
||||||
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
|
connectionProxyPoolId: resolvedProxy.proxyPoolId || null,
|
||||||
|
vercelRelayUrl: resolvedProxy.vercelRelayUrl || "",
|
||||||
},
|
},
|
||||||
connectionId: connection.id,
|
connectionId: connection.id,
|
||||||
// Include current status for optimization check
|
// Include current status for optimization check
|
||||||
|
|||||||
Reference in New Issue
Block a user