mirror of
https://github.com/langgenius/dify.git
synced 2026-06-06 08:00:00 +08:00
feat(web): add server oRPC client (#36856)
This commit is contained in:
@@ -27,7 +27,6 @@ vi.mock('@/next/navigation', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/features/account-profile/server', () => ({
|
||||
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
|
||||
serverUserProfileQueryOptions: () => ({
|
||||
queryKey: ['common', 'user-profile'],
|
||||
queryFn: mocks.profileQueryFn,
|
||||
@@ -35,8 +34,12 @@ vi.mock('@/features/account-profile/server', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/system-features', () => ({
|
||||
systemFeaturesQueryOptions: () => ({
|
||||
vi.mock('@/service/server', () => ({
|
||||
resolveServerConsoleApiUrl: (...args: unknown[]) => mocks.resolveServerConsoleApiUrl(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/server-system-features', () => ({
|
||||
serverSystemFeaturesQueryOptions: () => ({
|
||||
queryKey: ['console', 'system-features'],
|
||||
queryFn: mocks.systemFeaturesQueryFn,
|
||||
retry: false,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { dehydrate, HydrationBoundary } from '@tanstack/react-query'
|
||||
import { getQueryClientServer } from '@/context/query-client-server'
|
||||
import { resolveServerConsoleApiUrl, serverUserProfileQueryOptions } from '@/features/account-profile/server'
|
||||
import { serverUserProfileQueryOptions } from '@/features/account-profile/server'
|
||||
import { headers } from '@/next/headers'
|
||||
import { redirect } from '@/next/navigation'
|
||||
import { systemFeaturesQueryOptions } from '@/service/system-features'
|
||||
import { resolveServerConsoleApiUrl } from '@/service/server'
|
||||
import { serverSystemFeaturesQueryOptions } from '@/service/server-system-features'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const CURRENT_PATHNAME_HEADER = 'x-dify-pathname'
|
||||
@@ -71,7 +72,7 @@ export async function CommonLayoutHydrationBoundary({ children }: { children: Re
|
||||
try {
|
||||
await Promise.all([
|
||||
queryClient.fetchQuery(serverUserProfileQueryOptions()),
|
||||
queryClient.prefetchQuery(systemFeaturesQueryOptions()),
|
||||
queryClient.prefetchQuery(serverSystemFeaturesQueryOptions()),
|
||||
])
|
||||
}
|
||||
catch (error) {
|
||||
|
||||
@@ -4,8 +4,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: 'http://localhost:5001/console/api',
|
||||
CSRF_COOKIE_NAME: () => 'csrf_token',
|
||||
CSRF_HEADER_NAME: 'X-CSRF-Token',
|
||||
}))
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/config/server', () => ({
|
||||
SERVER_CONSOLE_API_PREFIX: undefined,
|
||||
}))
|
||||
|
||||
@@ -1,34 +1,10 @@
|
||||
import { API_PREFIX } from '@/config'
|
||||
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
|
||||
import { resolveServerConsoleApiUrl } from '@/service/server'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const REFRESH_TOKEN_PATH = '/refresh-token'
|
||||
const AUTH_REFRESH_PATH = `${basePath}/auth/refresh`
|
||||
const DEFAULT_REDIRECT_PATH = `${basePath}/apps`
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
const resolveServerConsoleApiUrl = (pathname: string) => {
|
||||
const requestPath = withoutLeadingSlash(pathname)
|
||||
const apiPrefix = SERVER_CONSOLE_API_PREFIX
|
||||
|| resolveAbsoluteUrlPrefix(API_PREFIX)
|
||||
|
||||
if (!apiPrefix)
|
||||
return null
|
||||
|
||||
return new URL(requestPath, withTrailingSlash(apiPrefix)).toString()
|
||||
}
|
||||
|
||||
const resolveSafeRedirectPath = (request: Request) => {
|
||||
const requestUrl = new URL(request.url)
|
||||
const redirectUrl = requestUrl.searchParams.get('redirect_url')
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { AccountProfileResponse } from '@/contract/console/account'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveServerConsoleApiUrl } from '@/service/server'
|
||||
import { userProfileQueryOptions } from '../client'
|
||||
import { resolveServerConsoleApiUrl } from '../server'
|
||||
|
||||
const headersMock = vi.fn()
|
||||
const cookiesMock = vi.fn()
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/config/server', () => ({
|
||||
SERVER_CONSOLE_API_PREFIX: undefined,
|
||||
}))
|
||||
|
||||
@@ -1,57 +1,13 @@
|
||||
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'
|
||||
import { getServerConsoleRequestHeaders, resolveServerConsoleApiUrl, serverConsoleQuery } from '@/service/server'
|
||||
|
||||
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()
|
||||
const outgoingHeaders = new Headers({
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
const cookie = requestHeaders.get('cookie')
|
||||
if (cookie)
|
||||
outgoingHeaders.set('cookie', cookie)
|
||||
const csrfToken = cookieStore.get(CSRF_COOKIE_NAME())?.value
|
||||
if (csrfToken)
|
||||
outgoingHeaders.set(CSRF_HEADER_NAME, csrfToken)
|
||||
return outgoingHeaders
|
||||
}
|
||||
|
||||
export const serverUserProfileQueryOptions = () =>
|
||||
queryOptions<UserProfileWithMeta>({
|
||||
queryKey: consoleQuery.account.profile.get.queryKey(),
|
||||
queryKey: serverConsoleQuery.account.profile.get.queryKey(),
|
||||
queryFn: async () => {
|
||||
const profileUrl = resolveServerConsoleApiUrl(ACCOUNT_PROFILE_PATH)
|
||||
if (!profileUrl)
|
||||
@@ -59,7 +15,7 @@ export const serverUserProfileQueryOptions = () =>
|
||||
|
||||
const response = await fetch(profileUrl, {
|
||||
method: 'GET',
|
||||
headers: await getServerRequestHeaders(),
|
||||
headers: await getServerConsoleRequestHeaders(),
|
||||
cache: 'no-store',
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
headers: vi.fn(),
|
||||
cookies: vi.fn(),
|
||||
serverConsoleApiPrefix: undefined as string | undefined,
|
||||
}))
|
||||
|
||||
vi.mock('server-only', () => ({}))
|
||||
|
||||
vi.mock('@/config', () => ({
|
||||
API_PREFIX: 'http://localhost:5001/console/api',
|
||||
CSRF_COOKIE_NAME: () => 'csrf_token',
|
||||
CSRF_HEADER_NAME: 'X-CSRF-Token',
|
||||
}))
|
||||
|
||||
vi.mock('@/config/server', () => ({
|
||||
get SERVER_CONSOLE_API_PREFIX() {
|
||||
return mocks.serverConsoleApiPrefix
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/next/headers', () => ({
|
||||
headers: () => mocks.headers(),
|
||||
cookies: () => mocks.cookies(),
|
||||
}))
|
||||
|
||||
describe('server console oRPC client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
mocks.serverConsoleApiPrefix = undefined
|
||||
mocks.headers.mockResolvedValue(new Headers({ cookie: 'access_token=abc; csrf_token=csrf-token' }))
|
||||
mocks.cookies.mockResolvedValue({
|
||||
get: vi.fn(() => ({ value: 'csrf-token' })),
|
||||
})
|
||||
})
|
||||
|
||||
it('should resolve server console API URLs only from configured or absolute prefixes', async () => {
|
||||
const { resolveServerConsoleApiPrefix, resolveServerConsoleApiUrl } = await import('../server')
|
||||
|
||||
expect(resolveServerConsoleApiPrefix(undefined, '/console/api')).toBeNull()
|
||||
expect(resolveServerConsoleApiUrl('/account/profile', undefined, '/console/api')).toBeNull()
|
||||
expect(
|
||||
resolveServerConsoleApiUrl('/account/profile', 'https://api.example.com/console/api', '/console/api'),
|
||||
).toBe('https://api.example.com/console/api/account/profile')
|
||||
expect(
|
||||
resolveServerConsoleApiUrl('/account/profile', undefined, 'https://public.example.com/console/api'),
|
||||
).toBe('https://public.example.com/console/api/account/profile')
|
||||
})
|
||||
|
||||
it('should build per-request context from Next headers and cookies', async () => {
|
||||
const { getServerConsoleClientContext } = await import('../server')
|
||||
|
||||
await expect(getServerConsoleClientContext()).resolves.toEqual({
|
||||
cookie: 'access_token=abc; csrf_token=csrf-token',
|
||||
csrfToken: 'csrf-token',
|
||||
})
|
||||
})
|
||||
|
||||
it('should call contracts with forwarded cookies, csrf header, and no-store cache', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ feature: { billing: false } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const { getServerConsoleClientContext, serverConsoleClient } = await import('../server')
|
||||
|
||||
await serverConsoleClient.systemFeatures(undefined, {
|
||||
context: await getServerConsoleClientContext(),
|
||||
})
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.any(Request),
|
||||
expect.objectContaining({
|
||||
cache: 'no-store',
|
||||
redirect: 'manual',
|
||||
}),
|
||||
)
|
||||
const request = fetchMock.mock.calls[0]?.[0] as Request
|
||||
expect(request.url).toBe('http://localhost:5001/console/api/system-features')
|
||||
expect(request.method).toBe('GET')
|
||||
expect(request.headers.get('accept')).toBe('application/json')
|
||||
expect(request.headers.get('content-type')).toBeNull()
|
||||
expect(request.headers.get('cookie')).toBe('access_token=abc; csrf_token=csrf-token')
|
||||
expect(request.headers.get('X-CSRF-Token')).toBe('csrf-token')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { SystemFeatures } from '@/types/feature'
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import { defaultSystemFeatures } from '@/types/feature'
|
||||
import {
|
||||
getServerConsoleClientContext,
|
||||
serverConsoleClient,
|
||||
serverConsoleQuery,
|
||||
} from './server'
|
||||
|
||||
export const serverSystemFeaturesQueryOptions = () =>
|
||||
queryOptions<SystemFeatures>({
|
||||
queryKey: serverConsoleQuery.systemFeatures.queryKey(),
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return await serverConsoleClient.systemFeatures(undefined, {
|
||||
context: await getServerConsoleClientContext(),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[systemFeatures] server fetch failed', err)
|
||||
return defaultSystemFeatures
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ContractRouterClient } from '@orpc/contract'
|
||||
import type { JsonifiedClient } from '@orpc/openapi-client'
|
||||
import { createORPCClient, onError } from '@orpc/client'
|
||||
import { OpenAPILink } from '@orpc/openapi-client/fetch'
|
||||
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
|
||||
import {
|
||||
API_PREFIX,
|
||||
CSRF_COOKIE_NAME,
|
||||
CSRF_HEADER_NAME,
|
||||
} from '@/config'
|
||||
import { SERVER_CONSOLE_API_PREFIX } from '@/config/server'
|
||||
import { consoleRouterContract } from '@/contract/router'
|
||||
|
||||
import 'server-only'
|
||||
|
||||
export type ServerConsoleClientContext = {
|
||||
cookie?: string
|
||||
csrfToken?: string
|
||||
}
|
||||
|
||||
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 resolveServerConsoleApiPrefix = (
|
||||
serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX,
|
||||
publicApiPrefix = API_PREFIX,
|
||||
) => serverConsoleApiPrefix || resolveAbsoluteUrlPrefix(publicApiPrefix)
|
||||
|
||||
export const resolveServerConsoleApiUrl = (
|
||||
pathname: string,
|
||||
serverConsoleApiPrefix = SERVER_CONSOLE_API_PREFIX,
|
||||
publicApiPrefix = API_PREFIX,
|
||||
) => {
|
||||
const apiPrefix = resolveServerConsoleApiPrefix(serverConsoleApiPrefix, publicApiPrefix)
|
||||
if (!apiPrefix)
|
||||
return null
|
||||
|
||||
return new URL(withoutLeadingSlash(pathname), withTrailingSlash(apiPrefix)).toString()
|
||||
}
|
||||
|
||||
const getServerConsoleApiPrefix = () => {
|
||||
const apiPrefix = resolveServerConsoleApiPrefix()
|
||||
if (!apiPrefix)
|
||||
throw new Error('Server console API URL is not configured')
|
||||
|
||||
return apiPrefix
|
||||
}
|
||||
|
||||
const createServerConsoleRequestHeaders = (context: ServerConsoleClientContext | undefined) => {
|
||||
const requestHeaders = new Headers({
|
||||
Accept: 'application/json',
|
||||
})
|
||||
|
||||
if (context?.cookie)
|
||||
requestHeaders.set('cookie', context.cookie)
|
||||
if (context?.csrfToken)
|
||||
requestHeaders.set(CSRF_HEADER_NAME, context.csrfToken)
|
||||
|
||||
return requestHeaders
|
||||
}
|
||||
|
||||
export const getServerConsoleClientContext = async (): Promise<ServerConsoleClientContext> => {
|
||||
const { cookies, headers } = await import('@/next/headers')
|
||||
const requestHeaders = await headers()
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return {
|
||||
cookie: requestHeaders.get('cookie') || undefined,
|
||||
csrfToken: cookieStore.get(CSRF_COOKIE_NAME())?.value,
|
||||
}
|
||||
}
|
||||
|
||||
export const getServerConsoleRequestHeaders = async () =>
|
||||
createServerConsoleRequestHeaders(await getServerConsoleClientContext())
|
||||
|
||||
const serverConsoleLink = new OpenAPILink<ServerConsoleClientContext>(consoleRouterContract, {
|
||||
url: getServerConsoleApiPrefix,
|
||||
headers: ({ context }) => createServerConsoleRequestHeaders(context),
|
||||
fetch: (request, init) => {
|
||||
if (request.body && !request.headers.has('content-type'))
|
||||
request.headers.set('Content-Type', 'application/json')
|
||||
|
||||
return globalThis.fetch(request, {
|
||||
...init,
|
||||
cache: 'no-store',
|
||||
})
|
||||
},
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error(error)
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
export const serverConsoleClient: JsonifiedClient<ContractRouterClient<typeof consoleRouterContract, ServerConsoleClientContext>> = createORPCClient(serverConsoleLink)
|
||||
|
||||
export const serverConsoleQuery = createTanstackQueryUtils(serverConsoleClient, {
|
||||
path: ['console'],
|
||||
})
|
||||
Reference in New Issue
Block a user