diff --git a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx
index 95bb612865..3d85179d06 100644
--- a/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx
+++ b/web/app/(commonLayout)/__tests__/hydration-boundary.spec.tsx
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
throw new Error(`NEXT_REDIRECT:${url}`)
}),
headers: vi.fn(),
+ resolveServerConsoleApiUrl: vi.fn(),
}))
vi.mock('@/context/query-client-server', () => ({
@@ -26,6 +27,7 @@ vi.mock('@/next/navigation', () => ({
}))
vi.mock('@/features/account-profile/server', () => ({
+ resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
serverUserProfileQueryOptions: () => ({
queryKey: ['common', 'user-profile'],
queryFn: mocks.profileQueryFn,
@@ -49,6 +51,7 @@ describe('CommonLayoutHydrationBoundary', () => {
'x-dify-pathname': '/apps',
'x-dify-search': '?tag=workflow',
}))
+ mocks.resolveServerConsoleApiUrl.mockReturnValue('https://console.example.com/console/api/account/profile')
mocks.profileQueryFn.mockResolvedValue({
profile: {
id: 'account-id',
@@ -100,4 +103,22 @@ describe('CommonLayoutHydrationBoundary', () => {
expect(mocks.redirect).toHaveBeenCalledWith('/install')
})
+
+ it('should render children without server prefetch when the server API URL is not resolvable', async () => {
+ mocks.resolveServerConsoleApiUrl.mockReturnValue(null)
+ const { CommonLayoutHydrationBoundary } = await import('../hydration-boundary')
+
+ const element = await CommonLayoutHydrationBoundary({
+ children:
Common shell
,
+ })
+
+ render(
+
+ {element as ReactElement}
+ ,
+ )
+ expect(screen.getByText('Common shell')).toBeInTheDocument()
+ expect(mocks.profileQueryFn).not.toHaveBeenCalled()
+ expect(mocks.systemFeaturesQueryFn).not.toHaveBeenCalled()
+ })
})
diff --git a/web/app/(commonLayout)/hydration-boundary.tsx b/web/app/(commonLayout)/hydration-boundary.tsx
index 29323b8403..ed148dcbe5 100644
--- a/web/app/(commonLayout)/hydration-boundary.tsx
+++ b/web/app/(commonLayout)/hydration-boundary.tsx
@@ -1,7 +1,7 @@
import type { ReactNode } from 'react'
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
import { getQueryClientServer } from '@/context/query-client-server'
-import { serverUserProfileQueryOptions } from '@/features/account-profile/server'
+import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server'
import { headers } from '@/next/headers'
import { redirect } from '@/next/navigation'
import { systemFeaturesQueryOptions } from '@/service/system-features'
@@ -9,6 +9,7 @@ import { basePath } from '@/utils/var'
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
const CURRENT_SEARCH_HEADER = 'x-dify-search'
+const ACCOUNT_PROFILE_PATH = '/account/profile'
type ConsoleErrorPayload = {
code?: string
@@ -56,6 +57,15 @@ const handleProfileError = async (error: unknown) => {
export async function CommonLayoutHydrationBoundary({ children }: { children: ReactNode }) {
const queryClient = getQueryClientServer()
+ const accountProfileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
+
+ if (!accountProfileUrl) {
+ return (
+
+ {children}
+
+ )
+ }
try {
await Promise.all([
diff --git a/web/config/server.ts b/web/config/server.ts
new file mode 100644
index 0000000000..c3844ac36d
--- /dev/null
+++ b/web/config/server.ts
@@ -0,0 +1,9 @@
+import { env } from '@/env'
+
+import 'server-only'
+
+const withoutTrailingSlash = (value: string) => value.endsWith('/') ? value.slice(0, -1) : value
+
+export const SERVER_CONSOLE_API_PREFIX = env.CONSOLE_API_URL
+ ? `${withoutTrailingSlash(env.CONSOLE_API_URL)}/console/api`
+ : undefined
diff --git a/web/env.ts b/web/env.ts
index 0c2868be5c..16a0ea2da1 100644
--- a/web/env.ts
+++ b/web/env.ts
@@ -142,6 +142,7 @@ const clientSchema = {
export const env = createEnv({
server: {
+ CONSOLE_API_URL: z.string().optional(),
/**
* Maximum length of segmentation tokens for indexing
*/
diff --git a/web/features/account-profile/__tests__/server.spec.ts b/web/features/account-profile/__tests__/server.spec.ts
index 1426e794f9..79ed7fa571 100644
--- a/web/features/account-profile/__tests__/server.spec.ts
+++ b/web/features/account-profile/__tests__/server.spec.ts
@@ -2,10 +2,15 @@ import type { AccountProfileResponse } from '@/contract/console/account'
import { QueryClient } from '@tanstack/react-query'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { userProfileQueryOptions } from '../client'
+import { resolveServerConsoleApiUrl } from '../server'
const headersMock = vi.fn()
const cookiesMock = vi.fn()
+vi.mock('@/config/server', () => ({
+ SERVER_CONSOLE_API_PREFIX: undefined,
+}))
+
vi.mock('@/next/headers', () => ({
headers: () => headersMock(),
cookies: () => cookiesMock(),
@@ -54,7 +59,7 @@ describe('serverUserProfileQueryOptions', () => {
},
})
expect(fetchMock).toHaveBeenCalledWith(
- expect.stringContaining('/account/profile'),
+ 'http://localhost:5001/console/api/account/profile',
expect.objectContaining({
method: 'GET',
cache: 'no-store',
@@ -62,4 +67,13 @@ describe('serverUserProfileQueryOptions', () => {
}),
)
})
+
+ it('should skip relative API prefixes unless a server API origin is configured', () => {
+ expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull()
+ expect(resolveServerConsoleApiUrl('/account/profile', 'https://console.example.com/console/api', '/console/api')).toBe('https://console.example.com/console/api/account/profile')
+ })
+
+ it('should preserve absolute API prefixes', () => {
+ expect(resolveServerConsoleApiUrl('/account/profile', undefined, 'https://console.example.com/console/api')).toBe('https://console.example.com/console/api/account/profile')
+ })
})
diff --git a/web/features/account-profile/server.ts b/web/features/account-profile/server.ts
index f270147713..8885f5f15f 100644
--- a/web/features/account-profile/server.ts
+++ b/web/features/account-profile/server.ts
@@ -2,11 +2,38 @@ import type { UserProfileWithMeta } from './client'
import type { AccountProfileResponse } from '@/contract/console/account'
import { queryOptions } from '@tanstack/react-query'
import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config'
+import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
import { cookies, headers } from '@/next/headers'
import { consoleQuery } from '@/service/client'
const ACCOUNT_PROFILE_PATH = '/account/profile'
+const withTrailingSlash = (value: string) => value.endsWith('/') ? value : `${value}/`
+const withoutLeadingSlash = (value: string) => value.startsWith('/') ? value.slice(1) : value
+
+const resolveAbsoluteUrlPrefix = (value: string) => {
+ try {
+ return new URL(value).toString()
+ }
+ catch {
+ return null
+ }
+}
+
+export const resolveServerConsoleApiUrl = (
+ pathname: string,
+ serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX,
+ publicApiPrefix = API_PREFIX,
+) => {
+ const requestPath = withoutLeadingSlash(pathname)
+ const apiPrefix = serverConsoleApiPrefix || resolveAbsoluteUrlPrefix(publicApiPrefix)
+
+ if (!apiPrefix)
+ return null
+
+ return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
+}
+
const getServerRequestHeaders = async () => {
const requestHeaders = await headers()
const cookieStore = await cookies()
@@ -26,7 +53,11 @@ export const serverUserProfileQueryOptions = () =>
queryOptions({
queryKey: consoleQuery.account.profile.get.queryKey(),
queryFn: async () => {
- const response = await fetch(`${API_PREFIX}${ACCOUNT_PROFILE_PATH}`, {
+ const profileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
+ if (!profileUrl)
+ throw new Error('Server account profile URL is not configured')
+
+ const response = await fetch(profileUrl, {
method: 'GET',
headers: await getServerRequestHeaders(),
cache: 'no-store',