From d8d95a14d33726f9b6a6264cb0e2481510014a57 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 1 Apr 2026 07:04:08 +0800 Subject: [PATCH] fix(user-center): fallback legacy agent node endpoint --- .../user-center/components/VlessQrCard.tsx | 25 +---- .../user-center/lib/fetchAgentNodes.test.ts | 93 +++++++++++++++++++ .../user-center/lib/fetchAgentNodes.ts | 78 ++++++++++++++++ .../components/SandboxNodeBindingPanel.tsx | 30 +----- .../builtin/user-center/routes/agent.tsx | 25 +---- 5 files changed, 177 insertions(+), 74 deletions(-) create mode 100644 src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts create mode 100644 src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts diff --git a/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx b/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx index 8af80ca..7227f0d 100644 --- a/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx +++ b/src/modules/extensions/builtin/user-center/components/VlessQrCard.tsx @@ -14,28 +14,7 @@ import { VlessNode, VlessTransport, } from '../lib/vless' - -async function fetcher(url: string): Promise { - const res = await fetch(url, { credentials: 'include', cache: 'no-store' }) - const payload = await res.json().catch(() => null) - - if (!res.ok) { - const message = - (payload && typeof payload.message === 'string' && payload.message) || - (payload && typeof payload.error === 'string' && payload.error) || - `Request failed (${res.status})` - throw new Error(message) - } - - if (Array.isArray(payload)) { - return payload as VlessNode[] - } - if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) { - return (payload as { nodes: VlessNode[] }).nodes - } - - return [] -} +import { fetchAgentNodes } from '../lib/fetchAgentNodes' export type VlessQrCopy = { label: string @@ -65,7 +44,7 @@ export default function VlessQrCard({ allowSandboxFallbackNode = false, boundNodeAddress, }: VlessQrCardProps) { - const { data: allNodes, error: nodesError } = useSWR('/api/agent-server/v1/nodes', fetcher) + const { data: allNodes, error: nodesError } = useSWR('user-center-agent-nodes', fetchAgentNodes) const nodes = useMemo(() => { return (allNodes ?? []).filter((node) => { diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts new file mode 100644 index 0000000..caff97e --- /dev/null +++ b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { fetchAgentNodes } from './fetchAgentNodes' + +describe('fetchAgentNodes', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('uses the primary endpoint when it returns a node array', async () => { + const fetchMock = vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify([{ name: 'JP', address: 'jp-xhttp.svc.plus' }]), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + ) + + await expect(fetchAgentNodes()).resolves.toEqual([{ name: 'JP', address: 'jp-xhttp.svc.plus' }]) + expect(fetchMock).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledWith( + '/api/agent-server/v1/nodes', + expect.objectContaining({ + cache: 'no-store', + credentials: 'include', + }), + ) + }) + + it('falls back to the legacy endpoint when the primary route is unavailable', async () => { + const fetchMock = vi + .spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'not_found' }), { + status: 404, + headers: { + 'Content-Type': 'application/json', + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify([{ name: 'US', address: 'us-xhttp.svc.plus' }]), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + ) + + await expect(fetchAgentNodes()).resolves.toEqual([{ name: 'US', address: 'us-xhttp.svc.plus' }]) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/agent/nodes') + }) + + it('falls back when the primary route returns an unexpected success payload', async () => { + const fetchMock = vi + .spyOn(global, 'fetch') + .mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ nodes: [{ name: 'HK', address: 'hk-xhttp.svc.plus' }] }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + }, + }), + ) + + await expect(fetchAgentNodes()).resolves.toEqual([{ name: 'HK', address: 'hk-xhttp.svc.plus' }]) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock.mock.calls[1]?.[0]).toBe('/api/agent/nodes') + }) + + it('preserves non-fallback errors from the primary endpoint', async () => { + vi.spyOn(global, 'fetch').mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'invalid_session' }), { + status: 401, + headers: { + 'Content-Type': 'application/json', + }, + }), + ) + + await expect(fetchAgentNodes()).rejects.toThrow('invalid_session') + }) +}) diff --git a/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts new file mode 100644 index 0000000..e1463e6 --- /dev/null +++ b/src/modules/extensions/builtin/user-center/lib/fetchAgentNodes.ts @@ -0,0 +1,78 @@ +'use client' + +import type { VlessNode } from './vless' + +const PRIMARY_ENDPOINT = '/api/agent-server/v1/nodes' +const FALLBACK_ENDPOINT = '/api/agent/nodes' + +type AgentNodePayload = { + nodes?: unknown + message?: unknown + error?: unknown +} + +type AgentNodesError = Error & { + status?: number +} + +function extractMessage(payload: AgentNodePayload | null, status: number): string { + if (payload && typeof payload.message === 'string' && payload.message.trim().length > 0) { + return payload.message + } + if (payload && typeof payload.error === 'string' && payload.error.trim().length > 0) { + return payload.error + } + return `Request failed (${status})` +} + +async function requestAgentNodes(url: string): Promise { + const response = await fetch(url, { + credentials: 'include', + cache: 'no-store', + headers: { + Accept: 'application/json', + }, + }) + + const payload = (await response.json().catch(() => null)) as AgentNodePayload | VlessNode[] | null + + if (!response.ok) { + const error = new Error(extractMessage(Array.isArray(payload) ? null : payload, response.status)) as AgentNodesError + error.status = response.status + throw error + } + + if (Array.isArray(payload)) { + return payload as VlessNode[] + } + if (payload && Array.isArray((payload as AgentNodePayload).nodes)) { + return (payload as { nodes: VlessNode[] }).nodes + } + + throw new Error('unexpected_agent_nodes_payload') +} + +function shouldFallback(error: unknown): boolean { + if (!(error instanceof Error)) { + return true + } + if ((error as AgentNodesError).status && [404, 405, 502].includes((error as AgentNodesError).status as number)) { + return true + } + return [ + 'unexpected_agent_nodes_payload', + 'upstream_unreachable', + ].includes(error.message) +} + +export async function fetchAgentNodes(): Promise { + try { + return await requestAgentNodes(PRIMARY_ENDPOINT) + } catch (error) { + if (!shouldFallback(error)) { + throw error + } + } + + return requestAgentNodes(FALLBACK_ENDPOINT) +} diff --git a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx b/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx index 724a0ca..6c30736 100644 --- a/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx +++ b/src/modules/extensions/builtin/user-center/management/components/SandboxNodeBindingPanel.tsx @@ -4,37 +4,11 @@ import { useEffect, useMemo, useState } from 'react' import useSWR from 'swr' import Card from '../../components/Card' +import { fetchAgentNodes } from '../../lib/fetchAgentNodes' import type { VlessNode } from '../../lib/vless' -async function fetcher(url: string): Promise { - const response = await fetch(url, { - credentials: 'include', - cache: 'no-store', - headers: { - Accept: 'application/json', - }, - }) - - const payload = await response.json().catch(() => null) - if (!response.ok) { - const message = - (payload && typeof payload.message === 'string' && payload.message) || - (payload && typeof payload.error === 'string' && payload.error) || - `Request failed (${response.status})` - throw new Error(message) - } - - if (Array.isArray(payload)) { - return payload as VlessNode[] - } - if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) { - return (payload as { nodes: VlessNode[] }).nodes - } - return [] -} - export default function SandboxNodeBindingPanel() { - const { data: nodes, error, isLoading } = useSWR('/api/agent-server/v1/nodes', fetcher, { + const { data: nodes, error, isLoading } = useSWR('user-center-agent-nodes', fetchAgentNodes, { revalidateOnFocus: false, }) const [message, setMessage] = useState(null) diff --git a/src/modules/extensions/builtin/user-center/routes/agent.tsx b/src/modules/extensions/builtin/user-center/routes/agent.tsx index 6545914..83a7806 100644 --- a/src/modules/extensions/builtin/user-center/routes/agent.tsx +++ b/src/modules/extensions/builtin/user-center/routes/agent.tsx @@ -8,6 +8,7 @@ import Breadcrumbs from '@/app/panel/components/Breadcrumbs' import { useLanguage } from '@i18n/LanguageProvider' import { translations } from '@i18n/translations' import { useUserStore } from '@lib/userStore' +import { fetchAgentNodes } from '../lib/fetchAgentNodes' import { fetchSandboxNodeBinding } from '../lib/sandboxNodeBinding' @@ -35,33 +36,11 @@ function isDisplayableNode(node: VlessNode): boolean { return true } -async function fetcher(url: string): Promise { - const res = await fetch(url, { credentials: 'include', cache: 'no-store' }) - - const payload = await res.json().catch(() => null) - if (!res.ok) { - const message = - (payload && typeof payload.message === 'string' && payload.message) || - (payload && typeof payload.error === 'string' && payload.error) || - `Request failed (${res.status})` - throw new Error(message) - } - - if (Array.isArray(payload)) { - return payload as VlessNode[] - } - if (payload && Array.isArray((payload as { nodes?: unknown }).nodes)) { - return (payload as { nodes: VlessNode[] }).nodes - } - - return [] -} - export default function UserCenterAgentRoute() { const { language } = useLanguage() const t = translations[language].userCenter const user = useUserStore((state) => state.user) - const { data: nodes, error, isLoading, mutate } = useSWR('/api/agent-server/v1/nodes', fetcher) + const { data: nodes, error, isLoading, mutate } = useSWR('user-center-agent-nodes', fetchAgentNodes) const [boundNode, setBoundNode] = useState(null) const normalizedEmail = user?.email?.toLowerCase() ?? '' const isGuestSandboxReadOnly = Boolean(