feat(web): add server oRPC client (#36856)

This commit is contained in:
yyh
2026-05-31 21:14:28 +08:00
committed by GitHub
parent 2fe8c48255
commit f75725ccd9
9 changed files with 245 additions and 79 deletions
@@ -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 -25
View File
@@ -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,
}))
+3 -47
View File
@@ -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',
})
+92
View File
@@ -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')
})
})
+24
View File
@@ -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
}
},
})
+108
View File
@@ -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'],
})