mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
Feat : Setup cloudflare worker for cloud endpoint
This commit is contained in:
@@ -5,7 +5,7 @@ import PropTypes from "prop-types";
|
||||
import { Card, Button, Input, Modal, CardSkeleton } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const DEFAULT_CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL || "";
|
||||
const CLOUD_ACTION_TIMEOUT_MS = 15000;
|
||||
|
||||
export default function APIPageClient({ machineId }) {
|
||||
@@ -17,8 +17,13 @@ export default function APIPageClient({ machineId }) {
|
||||
|
||||
// Cloud sync state
|
||||
const [cloudEnabled, setCloudEnabled] = useState(false);
|
||||
const [cloudUrl, setCloudUrl] = useState(DEFAULT_CLOUD_URL);
|
||||
const [cloudUrlInput, setCloudUrlInput] = useState(DEFAULT_CLOUD_URL);
|
||||
const [cloudUrlSaving, setCloudUrlSaving] = useState(false);
|
||||
const [showCloudModal, setShowCloudModal] = useState(false);
|
||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||
const [showSetupModal, setShowSetupModal] = useState(false);
|
||||
const [setupStatus, setSetupStatus] = useState(null);
|
||||
const [cloudSyncing, setCloudSyncing] = useState(false);
|
||||
const [cloudStatus, setCloudStatus] = useState(null);
|
||||
const [syncStep, setSyncStep] = useState(""); // "syncing" | "verifying" | "disabling" | ""
|
||||
@@ -58,6 +63,9 @@ export default function APIPageClient({ machineId }) {
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setCloudEnabled(data.cloudEnabled || false);
|
||||
const url = data.cloudUrl || DEFAULT_CLOUD_URL;
|
||||
setCloudUrl(url);
|
||||
setCloudUrlInput(url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading cloud settings:", error);
|
||||
@@ -169,6 +177,51 @@ export default function APIPageClient({ machineId }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveCloudUrl = async () => {
|
||||
// Strip trailing /v1 or /v1/ and trailing slashes
|
||||
const trimmed = cloudUrlInput.trim().replace(/\/v1\/?$/, "").replace(/\/+$/, "");
|
||||
if (!trimmed) return;
|
||||
|
||||
setCloudUrlSaving(true);
|
||||
setSetupStatus(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cloudUrl: trimmed }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setCloudUrl(trimmed);
|
||||
setCloudUrlInput(trimmed);
|
||||
setSetupStatus({ type: "success", message: "Worker URL saved" });
|
||||
} else {
|
||||
setSetupStatus({ type: "error", message: "Failed to save Worker URL" });
|
||||
}
|
||||
} catch (error) {
|
||||
setSetupStatus({ type: "error", message: error.message });
|
||||
} finally {
|
||||
setCloudUrlSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckCloud = async () => {
|
||||
if (!cloudUrl) return;
|
||||
setCloudSyncing(true);
|
||||
setSetupStatus(null);
|
||||
try {
|
||||
const { ok, data } = await postCloudAction("check", 8000);
|
||||
if (ok) {
|
||||
setSetupStatus({ type: "success", message: data.message || "Worker is running" });
|
||||
} else {
|
||||
setSetupStatus({ type: "error", message: data.error || "Check failed" });
|
||||
}
|
||||
} catch {
|
||||
setSetupStatus({ type: "error", message: "Cannot reach worker" });
|
||||
} finally {
|
||||
setCloudSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateKey = async () => {
|
||||
if (!newKeyName.trim()) return;
|
||||
|
||||
@@ -205,7 +258,7 @@ export default function APIPageClient({ machineId }) {
|
||||
};
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState("/v1");
|
||||
const cloudEndpointNew = `${CLOUD_URL}/v1`;
|
||||
const cloudEndpointNew = cloudUrl ? `${cloudUrl}/v1` : "";
|
||||
|
||||
// Hydration fix: Only access window on client side
|
||||
useEffect(() => {
|
||||
@@ -226,17 +279,10 @@ export default function APIPageClient({ machineId }) {
|
||||
// Use new format endpoint (machineId embedded in key)
|
||||
const currentEndpoint = cloudEnabled ? cloudEndpointNew : baseUrl;
|
||||
|
||||
const cloudBenefits = [
|
||||
{ icon: "public", title: "Access Anywhere", desc: "No port forwarding needed" },
|
||||
{ icon: "group", title: "Share Endpoint", desc: "Easy team collaboration" },
|
||||
{ icon: "schedule", title: "Always Online", desc: "24/7 availability" },
|
||||
{ icon: "speed", title: "Global Edge", desc: "Fast worldwide access" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Endpoint Card */}
|
||||
<Card className={cloudEnabled ? "" : ""}>
|
||||
<Card>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">API Endpoint</h2>
|
||||
@@ -245,6 +291,14 @@ export default function APIPageClient({ machineId }) {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="settings"
|
||||
onClick={() => setShowSetupModal(true)}
|
||||
>
|
||||
Setup Cloudflare
|
||||
</Button>
|
||||
{cloudEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -261,7 +315,7 @@ export default function APIPageClient({ machineId }) {
|
||||
variant="primary"
|
||||
icon="cloud_upload"
|
||||
onClick={() => handleCloudToggle(true)}
|
||||
disabled={cloudSyncing}
|
||||
disabled={cloudSyncing || !cloudUrl}
|
||||
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600"
|
||||
>
|
||||
Enable Cloud
|
||||
@@ -271,7 +325,7 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
|
||||
{/* Endpoint URL */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={currentEndpoint}
|
||||
readOnly
|
||||
@@ -286,6 +340,16 @@ export default function APIPageClient({ machineId }) {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Cloud Status */}
|
||||
{cloudStatus && (
|
||||
<div className={`mt-3 p-2 rounded text-sm ${
|
||||
cloudStatus.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
cloudStatus.type === "warning" ? "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{cloudStatus.message}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* API Keys */}
|
||||
@@ -344,62 +408,66 @@ export default function APIPageClient({ machineId }) {
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Cloud Proxy Card - Hidden */}
|
||||
{false && (
|
||||
<Card className={cloudEnabled ? "bg-primary/5" : ""}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`p-2 rounded-lg ${cloudEnabled ? "bg-primary text-white" : "bg-sidebar text-text-muted"}`}>
|
||||
<span className="material-symbols-outlined text-xl">cloud</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Cloud Proxy</h2>
|
||||
<p className="text-xs text-text-muted">
|
||||
{cloudEnabled ? "Connected & Ready" : "Access your API from anywhere"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cloudEnabled ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
icon="cloud_off"
|
||||
onClick={() => handleCloudToggle(false)}
|
||||
disabled={cloudSyncing}
|
||||
className="bg-red-500/10! text-red-500! hover:bg-red-500/20! border-red-500/30!"
|
||||
>
|
||||
Disable
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="cloud_upload"
|
||||
onClick={() => handleCloudToggle(true)}
|
||||
disabled={cloudSyncing}
|
||||
className="bg-linear-to-r from-primary to-blue-500 hover:from-primary-hover hover:to-blue-600 px-6"
|
||||
>
|
||||
Enable Cloud
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benefits Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{cloudBenefits.map((benefit) => (
|
||||
<div key={benefit.title} className="flex flex-col items-center text-center p-3 rounded-lg bg-sidebar/50">
|
||||
<span className="material-symbols-outlined text-xl text-primary mb-1">{benefit.icon}</span>
|
||||
<p className="text-xs font-semibold">{benefit.title}</p>
|
||||
<p className="text-xs text-text-muted">{benefit.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Setup Cloud Modal */}
|
||||
<Modal
|
||||
isOpen={showSetupModal}
|
||||
title="Setup Cloudflare Worker"
|
||||
onClose={() => { setShowSetupModal(false); setSetupStatus(null); }}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<p className="text-xs text-blue-700 dark:text-blue-300">
|
||||
<code className="font-semibold">https://9router.com</code> is a pre-configured worker ready to use. You can also deploy your own.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Worker URL</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={cloudUrlInput}
|
||||
onChange={(e) => setCloudUrlInput(e.target.value)}
|
||||
placeholder="https://9router.your-subdomain.workers.dev"
|
||||
className="flex-1 font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-2">
|
||||
Deploy your own worker from <code className="text-xs bg-sidebar px-1 py-0.5 rounded">app/cloud/</code> directory.{" "}
|
||||
<a href="https://github.com/decolua/9router/tree/main/app/cloud" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
|
||||
Setup guide →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status in modal */}
|
||||
{setupStatus && (
|
||||
<div className={`p-2 rounded text-sm ${
|
||||
setupStatus.type === "success" ? "bg-green-500/10 text-green-600 dark:text-green-400" :
|
||||
"bg-red-500/10 text-red-600 dark:text-red-400"
|
||||
}`}>
|
||||
{setupStatus.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSaveCloudUrl}
|
||||
fullWidth
|
||||
disabled={cloudUrlSaving || !cloudUrlInput.trim() || cloudUrlInput.trim().replace(/\/v1\/?$/, "").replace(/\/+$/, "") === cloudUrl}
|
||||
>
|
||||
{cloudUrlSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCheckCloud}
|
||||
variant="secondary"
|
||||
fullWidth
|
||||
disabled={cloudSyncing || !cloudUrl}
|
||||
icon="check_circle"
|
||||
>
|
||||
{cloudSyncing ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Cloud Enable Modal */}
|
||||
<Modal
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnections, getModelAliases, getCombos, getApiKeys, createApiKey, updateProviderConnection, updateSettings } from "@/lib/localDb";
|
||||
import { getProviderConnections, getModelAliases, getCombos, getApiKeys, createApiKey, updateProviderConnection, updateSettings, getCloudUrl } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const CLOUD_URL = process.env.CLOUD_URL || process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
const CLOUD_SYNC_TIMEOUT_MS = Number(process.env.CLOUD_SYNC_TIMEOUT_MS || 12000);
|
||||
|
||||
async function getResolvedCloudUrl() {
|
||||
return await getCloudUrl();
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = CLOUD_SYNC_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -50,6 +53,8 @@ export async function POST(request) {
|
||||
case "disable":
|
||||
await updateSettings({ cloudEnabled: false });
|
||||
return handleDisable(machineId, request);
|
||||
case "check":
|
||||
return handleCheck();
|
||||
default:
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
}
|
||||
@@ -65,8 +70,9 @@ export async function POST(request) {
|
||||
* @param {string|null} createdKey - Key created during enable
|
||||
*/
|
||||
export async function syncToCloud(machineId, createdKey = null) {
|
||||
if (!CLOUD_URL) {
|
||||
return { error: "NEXT_PUBLIC_CLOUD_URL is not configured" };
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
if (!cloudUrl) {
|
||||
return { error: "Cloud URL is not configured" };
|
||||
}
|
||||
|
||||
// Get current data from db
|
||||
@@ -78,7 +84,7 @@ export async function syncToCloud(machineId, createdKey = null) {
|
||||
let response;
|
||||
try {
|
||||
// Send to Cloud
|
||||
response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
response = await fetchWithTimeout(`${cloudUrl}/sync/${machineId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -140,7 +146,8 @@ async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
}
|
||||
|
||||
try {
|
||||
const pingResponse = await fetchWithTimeout(`${CLOUD_URL}/${machineId}/v1/verify`, {
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
const pingResponse = await fetchWithTimeout(`${cloudUrl}/${machineId}/v1/verify`, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
@@ -173,13 +180,14 @@ async function syncAndVerify(machineId, createdKey, existingKeys) {
|
||||
* Disable Cloud - delete cache and update Claude CLI settings
|
||||
*/
|
||||
async function handleDisable(machineId, request) {
|
||||
if (!CLOUD_URL) {
|
||||
return NextResponse.json({ error: "NEXT_PUBLIC_CLOUD_URL is not configured" }, { status: 500 });
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
if (!cloudUrl) {
|
||||
return NextResponse.json({ error: "Cloud URL is not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetchWithTimeout(`${CLOUD_URL}/sync/${machineId}`, {
|
||||
response = await fetchWithTimeout(`${cloudUrl}/sync/${machineId}`, {
|
||||
method: "DELETE"
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -198,7 +206,7 @@ async function handleDisable(machineId, request) {
|
||||
|
||||
// Update Claude CLI settings to use local endpoint
|
||||
const host = request.headers.get("host") || "localhost:20128";
|
||||
await updateClaudeSettingsToLocal(machineId, host);
|
||||
await updateClaudeSettingsToLocal(machineId, host, cloudUrl);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
@@ -209,10 +217,10 @@ async function handleDisable(machineId, request) {
|
||||
/**
|
||||
* Update Claude CLI settings to use local endpoint (only if currently using cloud)
|
||||
*/
|
||||
async function updateClaudeSettingsToLocal(machineId, host) {
|
||||
async function updateClaudeSettingsToLocal(machineId, host, cloudUrl) {
|
||||
try {
|
||||
const settingsPath = path.join(os.homedir(), ".claude", "settings.json");
|
||||
const cloudUrl = `${CLOUD_URL}/${machineId}`;
|
||||
const cloudEndpoint = `${cloudUrl}/${machineId}`;
|
||||
const localUrl = `http://${host}`;
|
||||
|
||||
// Read current settings
|
||||
@@ -229,19 +237,43 @@ async function updateClaudeSettingsToLocal(machineId, host) {
|
||||
|
||||
// Check if ANTHROPIC_BASE_URL matches cloud URL
|
||||
const currentUrl = settings.env?.ANTHROPIC_BASE_URL;
|
||||
if (!currentUrl || currentUrl !== cloudUrl) {
|
||||
if (!currentUrl || currentUrl !== cloudEndpoint) {
|
||||
return; // Not using cloud URL, don't modify
|
||||
}
|
||||
|
||||
// Update to local URL
|
||||
settings.env.ANTHROPIC_BASE_URL = localUrl;
|
||||
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2));
|
||||
console.log(`Updated Claude CLI settings: ${cloudUrl} → ${localUrl}`);
|
||||
console.log(`Updated Claude CLI settings: ${cloudEndpoint} → ${localUrl}`);
|
||||
} catch (error) {
|
||||
console.log("Failed to update Claude CLI settings:", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if cloud worker is reachable
|
||||
*/
|
||||
async function handleCheck() {
|
||||
const cloudUrl = await getResolvedCloudUrl();
|
||||
if (!cloudUrl) {
|
||||
return NextResponse.json({ error: "Cloud URL is not configured" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${cloudUrl}/health`, { method: "GET" }, 5000);
|
||||
if (res.ok) {
|
||||
return NextResponse.json({ success: true, message: "Worker is running" });
|
||||
}
|
||||
return NextResponse.json({ error: `Worker responded with ${res.status}` }, { status: 502 });
|
||||
} catch (error) {
|
||||
const isTimeout = error?.name === "AbortError";
|
||||
return NextResponse.json(
|
||||
{ error: isTimeout ? "Worker request timeout" : "Cannot reach worker" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update local db with data from Cloud
|
||||
* Simple logic: if Cloud is newer, sync entire provider
|
||||
|
||||
Reference in New Issue
Block a user