fix(user-center): fallback legacy agent node endpoint

This commit is contained in:
Haitao Pan 2026-04-01 07:04:08 +08:00
parent 41760a0227
commit d8d95a14d3
5 changed files with 177 additions and 74 deletions

View File

@ -14,28 +14,7 @@ import {
VlessNode,
VlessTransport,
} from '../lib/vless'
async function fetcher(url: string): Promise<VlessNode[]> {
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<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
const { data: allNodes, error: nodesError } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes)
const nodes = useMemo(() => {
return (allNodes ?? []).filter((node) => {

View File

@ -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')
})
})

View File

@ -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<VlessNode[]> {
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<VlessNode[]> {
try {
return await requestAgentNodes(PRIMARY_ENDPOINT)
} catch (error) {
if (!shouldFallback(error)) {
throw error
}
}
return requestAgentNodes(FALLBACK_ENDPOINT)
}

View File

@ -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<VlessNode[]> {
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<VlessNode[]>('/api/agent-server/v1/nodes', fetcher, {
const { data: nodes, error, isLoading } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes, {
revalidateOnFocus: false,
})
const [message, setMessage] = useState<string | null>(null)

View File

@ -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<VlessNode[]> {
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<VlessNode[]>('/api/agent-server/v1/nodes', fetcher)
const { data: nodes, error, isLoading, mutate } = useSWR<VlessNode[]>('user-center-agent-nodes', fetchAgentNodes)
const [boundNode, setBoundNode] = useState<VlessNode | null>(null)
const normalizedEmail = user?.email?.toLowerCase() ?? ''
const isGuestSandboxReadOnly = Boolean(