feat(proxy): add proxy pool and per-connection binding + strictProxy support

- Centralize proxy management with reusable proxy pools
- Per-connection proxy binding with legacy fallback
- Add strictProxy option: fail hard instead of silently falling back to direct
- Resolve alicode-intl conflict: keep alicode-intl support + proxy support

Made-with: Cursor
This commit is contained in:
decolua
2026-03-09 15:46:06 +07:00
parent 4c469291a1
commit 880f4eca91
22 changed files with 1811 additions and 146 deletions

View File

@@ -0,0 +1,118 @@
import { NextResponse } from "next/server";
import {
deleteProxyPool,
getProviderConnections,
getProxyPoolById,
updateProxyPool,
} from "@/models";
function normalizeProxyPoolUpdate(body = {}) {
const updates = {};
if (Object.prototype.hasOwnProperty.call(body, "name")) {
const name = typeof body?.name === "string" ? body.name.trim() : "";
if (!name) {
return { error: "Name is required" };
}
updates.name = name;
}
if (Object.prototype.hasOwnProperty.call(body, "proxyUrl")) {
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
if (!proxyUrl) {
return { error: "Proxy URL is required" };
}
updates.proxyUrl = proxyUrl;
}
if (Object.prototype.hasOwnProperty.call(body, "noProxy")) {
updates.noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
}
if (Object.prototype.hasOwnProperty.call(body, "isActive")) {
updates.isActive = body?.isActive === true;
}
if (Object.prototype.hasOwnProperty.call(body, "strictProxy")) {
updates.strictProxy = body?.strictProxy === true;
}
return { updates };
}
function countBoundConnections(connections = [], proxyPoolId) {
return connections.filter((connection) => connection?.providerSpecificData?.proxyPoolId === proxyPoolId).length;
}
// GET /api/proxy-pools/[id] - Get proxy pool
export async function GET(request, { params }) {
try {
const { id } = await params;
const proxyPool = await getProxyPoolById(id);
if (!proxyPool) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
return NextResponse.json({ proxyPool });
} catch (error) {
console.log("Error fetching proxy pool:", error);
return NextResponse.json({ error: "Failed to fetch proxy pool" }, { status: 500 });
}
}
// PUT /api/proxy-pools/[id] - Update proxy pool
export async function PUT(request, { params }) {
try {
const { id } = await params;
const existing = await getProxyPoolById(id);
if (!existing) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
const body = await request.json();
const normalized = normalizeProxyPoolUpdate(body);
if (normalized.error) {
return NextResponse.json({ error: normalized.error }, { status: 400 });
}
const updated = await updateProxyPool(id, normalized.updates);
return NextResponse.json({ proxyPool: updated });
} catch (error) {
console.log("Error updating proxy pool:", error);
return NextResponse.json({ error: "Failed to update proxy pool" }, { status: 500 });
}
}
// DELETE /api/proxy-pools/[id] - Delete proxy pool
export async function DELETE(request, { params }) {
try {
const { id } = await params;
const existing = await getProxyPoolById(id);
if (!existing) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
const connections = await getProviderConnections();
const boundConnectionCount = countBoundConnections(connections, id);
if (boundConnectionCount > 0) {
return NextResponse.json(
{
error: "Proxy pool is currently in use",
boundConnectionCount,
},
{ status: 409 }
);
}
await deleteProxyPool(id);
return NextResponse.json({ success: true });
} catch (error) {
console.log("Error deleting proxy pool:", error);
return NextResponse.json({ error: "Failed to delete proxy pool" }, { status: 500 });
}
}

View File

@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { getProxyPoolById, updateProxyPool } from "@/models";
import { testProxyUrl } from "@/lib/network/proxyTest";
// POST /api/proxy-pools/[id]/test - Test proxy pool entry
export async function POST(request, { params }) {
try {
const { id } = await params;
const proxyPool = await getProxyPoolById(id);
if (!proxyPool) {
return NextResponse.json({ error: "Proxy pool not found" }, { status: 404 });
}
const result = await testProxyUrl({ proxyUrl: proxyPool.proxyUrl });
const now = new Date().toISOString();
await updateProxyPool(id, {
testStatus: result.ok ? "active" : "error",
lastTestedAt: now,
lastError: result.ok ? null : (result.error || `Proxy test failed with status ${result.status}`),
isActive: result.ok,
});
return NextResponse.json({
ok: result.ok,
status: result.status,
statusText: result.statusText || null,
error: result.error || null,
elapsedMs: result.elapsedMs || 0,
testedAt: now,
});
} catch (error) {
console.log("Error testing proxy pool:", error);
return NextResponse.json({ error: "Failed to test proxy pool" }, { status: 500 });
}
}

View File

@@ -0,0 +1,101 @@
import { NextResponse } from "next/server";
import {
createProxyPool,
getProviderConnections,
getProxyPools,
updateProviderConnection,
} from "@/models";
function normalizeString(value) {
if (value === undefined || value === null) return "";
return String(value).trim();
}
function buildProxyKey(proxyUrl, noProxy) {
return `${normalizeString(proxyUrl)}|||${normalizeString(noProxy)}`;
}
function extractLegacyProxy(connection) {
const providerSpecificData = connection?.providerSpecificData || {};
const connectionProxyEnabled = providerSpecificData.connectionProxyEnabled === true;
const connectionProxyUrl = normalizeString(providerSpecificData.connectionProxyUrl);
const connectionNoProxy = normalizeString(providerSpecificData.connectionNoProxy);
if (!connectionProxyEnabled || !connectionProxyUrl) {
return null;
}
return {
connectionProxyUrl,
connectionNoProxy,
};
}
function buildMigratedName(index) {
return `Migrated Proxy ${index}`;
}
// POST /api/proxy-pools/migrate - Migrate legacy connection proxy config into proxy pools
export async function POST() {
try {
const connections = await getProviderConnections();
const existingPools = await getProxyPools();
const poolByKey = new Map();
for (const pool of existingPools) {
const key = buildProxyKey(pool.proxyUrl, pool.noProxy);
if (!poolByKey.has(key)) {
poolByKey.set(key, pool);
}
}
let migratedConnectionCount = 0;
let legacyConnectionCount = 0;
const createdPools = [];
for (const connection of connections) {
const legacyProxy = extractLegacyProxy(connection);
if (!legacyProxy) continue;
legacyConnectionCount += 1;
const key = buildProxyKey(legacyProxy.connectionProxyUrl, legacyProxy.connectionNoProxy);
let pool = poolByKey.get(key);
if (!pool) {
pool = await createProxyPool({
name: buildMigratedName(existingPools.length + createdPools.length + 1),
proxyUrl: legacyProxy.connectionProxyUrl,
noProxy: legacyProxy.connectionNoProxy,
isActive: true,
testStatus: "unknown",
});
createdPools.push(pool);
poolByKey.set(key, pool);
}
if (connection?.providerSpecificData?.proxyPoolId !== pool.id) {
await updateProviderConnection(connection.id, {
providerSpecificData: {
...(connection.providerSpecificData || {}),
proxyPoolId: pool.id,
},
});
migratedConnectionCount += 1;
}
}
return NextResponse.json({
success: true,
summary: {
totalConnections: connections.length,
legacyConnections: legacyConnectionCount,
poolsCreated: createdPools.length,
connectionsBound: migratedConnectionCount,
},
createdPools,
});
} catch (error) {
console.log("Error migrating proxy pools:", error);
return NextResponse.json({ error: "Failed to migrate proxy pools" }, { status: 500 });
}
}

View File

@@ -0,0 +1,90 @@
import { NextResponse } from "next/server";
import { createProxyPool, getProviderConnections, getProxyPools } from "@/models";
function toBoolean(value) {
if (value === "true") return true;
if (value === "false") return false;
return undefined;
}
function normalizeProxyPoolInput(body = {}) {
const name = typeof body?.name === "string" ? body.name.trim() : "";
const proxyUrl = typeof body?.proxyUrl === "string" ? body.proxyUrl.trim() : "";
const noProxy = typeof body?.noProxy === "string" ? body.noProxy.trim() : "";
const isActive = body?.isActive === undefined ? true : body.isActive === true;
const strictProxy = body?.strictProxy === true;
if (!name) {
return { error: "Name is required" };
}
if (!proxyUrl) {
return { error: "Proxy URL is required" };
}
return { name, proxyUrl, noProxy, isActive, strictProxy };
}
function buildUsageMap(connections = []) {
const usageMap = new Map();
for (const connection of connections) {
const proxyPoolId = connection?.providerSpecificData?.proxyPoolId;
if (!proxyPoolId) continue;
usageMap.set(proxyPoolId, (usageMap.get(proxyPoolId) || 0) + 1);
}
return usageMap;
}
// GET /api/proxy-pools - List proxy pools
export async function GET(request) {
try {
const { searchParams } = new URL(request.url);
const isActive = toBoolean(searchParams.get("isActive"));
const includeUsage = searchParams.get("includeUsage") === "true";
const filter = {};
if (isActive !== undefined) {
filter.isActive = isActive;
}
const proxyPools = await getProxyPools(filter);
if (!includeUsage) {
return NextResponse.json({ proxyPools });
}
const connections = await getProviderConnections();
const usageMap = buildUsageMap(connections);
const enrichedProxyPools = proxyPools.map((pool) => ({
...pool,
boundConnectionCount: usageMap.get(pool.id) || 0,
}));
return NextResponse.json({ proxyPools: enrichedProxyPools });
} catch (error) {
console.log("Error fetching proxy pools:", error);
return NextResponse.json({ error: "Failed to fetch proxy pools" }, { status: 500 });
}
}
// POST /api/proxy-pools - Create proxy pool
export async function POST(request) {
try {
const body = await request.json();
const normalized = normalizeProxyPoolInput(body);
if (normalized.error) {
return NextResponse.json({ error: normalized.error }, { status: 400 });
}
const proxyPool = await createProxyPool(normalized);
return NextResponse.json({ proxyPool }, { status: 201 });
} catch (error) {
console.log("Error creating proxy pool:", error);
return NextResponse.json({ error: "Failed to create proxy pool" }, { status: 500 });
}
}