chore(web): add enterprise dev proxy support (#35842)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-05-06 17:26:34 +08:00 committed by GitHub
parent 8967ff34b3
commit f3c3534e33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 340 additions and 20 deletions

View File

@ -5325,11 +5325,6 @@
"count": 1
}
},
"web/plugins/dev-proxy/server.spec.ts": {
"ts/no-explicit-any": {
"count": 1
}
},
"web/scripts/component-analyzer.js": {
"regexp/no-unused-capturing-group": {
"count": 6

View File

@ -21,10 +21,14 @@ NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001
# The frontend keeps requesting http://localhost:5001 directly,
# the proxy server will forward the request to the target server,
# so that you don't need to run a separate backend server and use online API in development.
# Supported values: dify, enterprise.
# Defaults to dify. Enterprise target listens on port 8082 by default.
HONO_PROXY_TARGET=dify
HONO_PROXY_HOST=127.0.0.1
HONO_PROXY_PORT=5001
HONO_PROXY_PORT=
HONO_CONSOLE_API_PROXY_TARGET=
HONO_PUBLIC_API_PROXY_TARGET=
HONO_ENTERPRISE_API_PROXY_TARGET=
# The API PREFIX for MARKETPLACE
NEXT_PUBLIC_MARKETPLACE_API_PREFIX=https://marketplace.dify.ai/api/v1

View File

@ -57,6 +57,8 @@ pnpm -C web run dev
pnpm -C web run dev:vinext
# (optional) start the dev proxy server so that you can use online API in development
pnpm -C web run dev:proxy
# (optional) start the dev proxy for the Enterprise frontend; it listens on 8082 by default
pnpm -C web run dev:proxy -- --target enterprise
```
Open <http://localhost:3000> with your browser to see the result.

View File

@ -0,0 +1,79 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { parseDevProxyCliArgs, resolveDevProxyServerOptions, resolveDevProxyTarget } from './config'
describe('dev proxy config', () => {
// Scenario: CLI options should support both inline and separated values.
it('should parse proxy CLI options', () => {
// Act
const options = parseDevProxyCliArgs([
'--target=enterprise',
'--host',
'0.0.0.0',
'--port',
'8083',
])
// Assert
expect(options).toEqual({
host: '0.0.0.0',
port: '8083',
proxyTarget: 'enterprise',
})
})
// Scenario: the default Dify proxy keeps the existing 5001 port.
it('should resolve the default Dify proxy server options', () => {
// Act
const options = resolveDevProxyServerOptions()
// Assert
expect(options).toEqual({
host: '127.0.0.1',
port: 5001,
proxyTarget: 'dify',
})
})
// Scenario: Enterprise frontend defaults to the Enterprise gateway port.
it('should use port 8082 by default for enterprise proxy target', () => {
// Act
const options = resolveDevProxyServerOptions({}, {
proxyTarget: 'enterprise',
})
// Assert
expect(options).toEqual({
host: '127.0.0.1',
port: 8082,
proxyTarget: 'enterprise',
})
})
// Scenario: explicit ports should override target-specific defaults.
it('should allow env and CLI ports to override the default port', () => {
// Act
const envOptions = resolveDevProxyServerOptions({
HONO_PROXY_PORT: '9001',
HONO_PROXY_TARGET: 'enterprise',
})
const cliOptions = resolveDevProxyServerOptions({
HONO_PROXY_PORT: '9001',
HONO_PROXY_TARGET: 'enterprise',
}, {
port: '9002',
})
// Assert
expect(envOptions.port).toBe(9001)
expect(cliOptions.port).toBe(9002)
})
// Scenario: unsupported proxy targets should fail before the server starts.
it('should reject unsupported proxy targets', () => {
// Assert
expect(() => resolveDevProxyTarget('unknown')).toThrow('Unsupported proxy target')
})
})

View File

@ -0,0 +1,103 @@
const DEV_PROXY_TARGETS = ['dify', 'enterprise'] as const
type DevProxyTarget = typeof DEV_PROXY_TARGETS[number]
type DevProxyConfigEnv = Partial<Record<
| 'HONO_PROXY_HOST'
| 'HONO_PROXY_PORT'
| 'HONO_PROXY_TARGET',
string
>>
type DevProxyCliOptions = {
host?: string
port?: string
proxyTarget?: string
}
type DevProxyServerOptions = {
host: string
port: number
proxyTarget: DevProxyTarget
}
const DEFAULT_PROXY_HOST = '127.0.0.1'
const DEFAULT_PROXY_TARGET: DevProxyTarget = 'dify'
const DEFAULT_PROXY_PORT_BY_TARGET: Record<DevProxyTarget, number> = {
dify: 5001,
enterprise: 8082,
}
const OPTION_NAME_TO_KEY = {
'--host': 'host',
'--port': 'port',
'--proxy-target': 'proxyTarget',
'--target': 'proxyTarget',
} as const
type OptionName = keyof typeof OPTION_NAME_TO_KEY
const isOptionName = (value: string): value is OptionName => value in OPTION_NAME_TO_KEY
const requireOptionValue = (name: string, value?: string) => {
if (!value || isOptionName(value))
throw new Error(`Missing value for ${name}.`)
return value
}
export const parseDevProxyCliArgs = (argv: string[]): DevProxyCliOptions => {
const options: DevProxyCliOptions = {}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]!
const [rawName, inlineValue] = arg.split('=', 2)
const name = rawName ?? ''
if (!isOptionName(name))
continue
const key = OPTION_NAME_TO_KEY[name]
options[key] = inlineValue ?? requireOptionValue(name, argv[index + 1])
if (inlineValue === undefined)
index += 1
}
return options
}
export const resolveDevProxyTarget = (target?: string): DevProxyTarget => {
if (!target)
return DEFAULT_PROXY_TARGET
const normalizedTarget = target.trim().toLowerCase()
if (DEV_PROXY_TARGETS.includes(normalizedTarget as DevProxyTarget))
return normalizedTarget as DevProxyTarget
throw new Error(`Unsupported proxy target "${target}". Expected "dify" or "enterprise".`)
}
const resolvePort = (rawPort: string) => {
const port = Number(rawPort)
if (!Number.isInteger(port) || port < 1 || port > 65535)
throw new Error(`Invalid proxy port "${rawPort}". Expected an integer between 1 and 65535.`)
return port
}
export const resolveDevProxyServerOptions = (
env: DevProxyConfigEnv = {},
cliOptions: DevProxyCliOptions = {},
): DevProxyServerOptions => {
const proxyTarget = resolveDevProxyTarget(cliOptions.proxyTarget || env.HONO_PROXY_TARGET)
const configuredPort = cliOptions.port || env.HONO_PROXY_PORT
return {
host: cliOptions.host || env.HONO_PROXY_HOST || DEFAULT_PROXY_HOST,
port: configuredPort
? resolvePort(configuredPort)
: DEFAULT_PROXY_PORT_BY_TARGET[proxyTarget],
proxyTarget,
}
}

View File

@ -36,10 +36,15 @@ const toUpstreamCookieName = (cookieName: string) => {
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
export const rewriteCookieHeaderForUpstream = (
cookieHeader?: string,
options: { useHostPrefix?: boolean } = {},
) => {
if (!cookieHeader)
return cookieHeader
const { useHostPrefix = true } = options
return cookieHeader
.split(/;\s*/)
.filter(Boolean)
@ -50,7 +55,7 @@ export const rewriteCookieHeaderForUpstream = (cookieHeader?: string) => {
const cookieName = cookie.slice(0, separatorIndex).trim()
const cookieValue = cookie.slice(separatorIndex + 1)
return `${toUpstreamCookieName(cookieName)}=${cookieValue}`
return `${useHostPrefix ? toUpstreamCookieName(cookieName) : cookieName}=${cookieValue}`
})
.join('; ')
}

View File

@ -15,11 +15,26 @@ describe('dev proxy server', () => {
const targets = resolveDevProxyTargets({
HONO_CONSOLE_API_PROXY_TARGET: 'https://console.example.com',
HONO_PUBLIC_API_PROXY_TARGET: 'https://public.example.com',
HONO_ENTERPRISE_API_PROXY_TARGET: 'https://enterprise.example.com',
})
// Assert
expect(targets.consoleApiTarget).toBe('https://console.example.com')
expect(targets.publicApiTarget).toBe('https://public.example.com')
expect(targets.enterpriseApiTarget).toBe('https://enterprise.example.com')
})
// Scenario: optional proxy targets should use their route-specific defaults.
it('should use console target as the default for optional targets', () => {
// Act
const targets = resolveDevProxyTargets({
HONO_CONSOLE_API_PROXY_TARGET: 'https://console.example.com',
})
// Assert
expect(targets.consoleApiTarget).toBe('https://console.example.com')
expect(targets.publicApiTarget).toBe('https://console.example.com')
expect(targets.enterpriseApiTarget).toBeUndefined()
})
// Scenario: target paths should not be duplicated when the incoming route already includes them.
@ -54,14 +69,16 @@ describe('dev proxy server', () => {
const app = createDevProxyApp({
consoleApiTarget: 'https://cloud.dify.ai',
publicApiTarget: 'https://public.dify.ai',
enterpriseApiTarget: 'https://enterprise.dify.ai',
fetchImpl,
})
// Act
const response = await app.request('http://127.0.0.1:5001/console/api/apps?page=1', {
headers: {
Origin: 'http://localhost:3000',
Cookie: 'access_token=abc',
'Origin': 'http://localhost:3000',
'Cookie': 'access_token=abc',
'Accept-Encoding': 'zstd, br, gzip',
},
})
@ -75,10 +92,13 @@ describe('dev proxy server', () => {
}),
)
const [, requestInit] = (fetchImpl.mock.calls[0] ?? []) as [unknown, any]
const requestHeaders = requestInit?.headers as Headers
const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers
if (!(requestHeaders instanceof Headers))
throw new Error('Expected proxy request headers to be Headers')
expect(requestHeaders.get('cookie')).toBe('__Host-access_token=abc')
expect(requestHeaders.get('origin')).toBe('https://cloud.dify.ai')
expect(requestHeaders.get('accept-encoding')).toBe('identity')
expect(response.headers.get('access-control-allow-origin')).toBe('http://localhost:3000')
expect(response.headers.get('access-control-allow-credentials')).toBe('true')
expect(response.headers.get('content-encoding')).toBeNull()
@ -89,12 +109,83 @@ describe('dev proxy server', () => {
])
})
// Scenario: a local HTTP Dify API expects the non-prefixed local cookie name.
it('should keep local cookie names for HTTP upstream targets', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
const app = createDevProxyApp({
consoleApiTarget: 'http://127.0.0.1:5001',
publicApiTarget: 'http://127.0.0.1:5001',
enterpriseApiTarget: 'http://127.0.0.1:8082',
fetchImpl,
})
// Act
await app.request('http://127.0.0.1:5010/console/api/account/profile', {
headers: {
Cookie: 'access_token=abc; refresh_token=def',
},
})
// Assert
const requestHeaders = fetchImpl.mock.calls[0]?.[1]?.headers
if (!(requestHeaders instanceof Headers))
throw new Error('Expected proxy request headers to be Headers')
expect(requestHeaders.get('cookie')).toBe('access_token=abc; refresh_token=def')
})
// Scenario: Enterprise dashboard routes should use the Enterprise target before generic API routes.
it('should proxy enterprise api routes to the enterprise target', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
const app = createDevProxyApp({
consoleApiTarget: 'https://console.example.com',
publicApiTarget: 'https://public.example.com',
enterpriseApiTarget: 'https://enterprise.example.com',
fetchImpl,
})
const requestUrls = [
'http://127.0.0.1:5001/console/api/enterprise/sso/saml/login',
'http://127.0.0.1:5001/api/enterprise/sso/oauth2/login',
'http://127.0.0.1:5001/admin-api/v1/workspaces',
'http://127.0.0.1:5001/inner/api/info',
'http://127.0.0.1:5001/mfa/v1/verify',
'http://127.0.0.1:5001/scim/v2/Users',
'http://127.0.0.1:5001/v1/audit/logs',
'http://127.0.0.1:5001/v1/dashboard/api/license/status',
'http://127.0.0.1:5001/v1/healthz',
'http://127.0.0.1:5001/v1/plugin-manager/plugins',
]
// Act
for (const url of requestUrls)
await app.request(url)
// Assert
expect(fetchImpl).toHaveBeenCalledTimes(requestUrls.length)
expect(fetchImpl.mock.calls.map(([url]) => url.toString())).toEqual([
'https://enterprise.example.com/console/api/enterprise/sso/saml/login',
'https://enterprise.example.com/api/enterprise/sso/oauth2/login',
'https://enterprise.example.com/admin-api/v1/workspaces',
'https://enterprise.example.com/inner/api/info',
'https://enterprise.example.com/mfa/v1/verify',
'https://enterprise.example.com/scim/v2/Users',
'https://enterprise.example.com/v1/audit/logs',
'https://enterprise.example.com/v1/dashboard/api/license/status',
'https://enterprise.example.com/v1/healthz',
'https://enterprise.example.com/v1/plugin-manager/plugins',
])
})
// Scenario: preflight requests should advertise allowed headers for credentialed cross-origin calls.
it('should answer CORS preflight requests', async () => {
// Arrange
const app = createDevProxyApp({
consoleApiTarget: 'https://cloud.dify.ai',
publicApiTarget: 'https://public.dify.ai',
enterpriseApiTarget: 'https://enterprise.dify.ai',
fetchImpl: vi.fn<typeof fetch>(),
})

View File

@ -4,13 +4,15 @@ import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieH
type DevProxyEnv = Partial<Record<
| 'HONO_CONSOLE_API_PROXY_TARGET'
| 'HONO_PUBLIC_API_PROXY_TARGET',
| 'HONO_PUBLIC_API_PROXY_TARGET'
| 'HONO_ENTERPRISE_API_PROXY_TARGET',
string
>>
type DevProxyTargets = {
consoleApiTarget: string
publicApiTarget: string
enterpriseApiTarget?: string
}
type DevProxyAppOptions = DevProxyTargets & {
@ -20,6 +22,7 @@ type DevProxyAppOptions = DevProxyTargets & {
const LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]'])
const ALLOW_METHODS = 'GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS'
const DEFAULT_ALLOW_HEADERS = 'Authorization, Content-Type, X-CSRF-Token'
const UPSTREAM_ACCEPT_ENCODING = 'identity'
const RESPONSE_HEADERS_TO_DROP = [
'connection',
'content-encoding',
@ -29,6 +32,27 @@ const RESPONSE_HEADERS_TO_DROP = [
'transfer-encoding',
] as const
const ENTERPRISE_API_ROUTES = [
'/console/api/enterprise',
'/api/enterprise',
'/admin-api',
'/inner/api',
'/mfa',
'/scim',
'/v1/audit',
'/v1/dashboard',
'/v1/healthz',
'/v1/plugin-manager',
] as const
const CONSOLE_API_ROUTES = ['/console/api'] as const
const PUBLIC_API_ROUTES = ['/api'] as const
type ProxyRoutePath
= | typeof ENTERPRISE_API_ROUTES[number]
| typeof CONSOLE_API_ROUTES[number]
| typeof PUBLIC_API_ROUTES[number]
const appendHeaderValue = (headers: Headers, name: string, value: string) => {
const currentValue = headers.get(name)
if (!currentValue) {
@ -82,11 +106,14 @@ export const buildUpstreamUrl = (target: string, requestPath: string, search = '
const createProxyRequestHeaders = (request: Request, targetUrl: URL) => {
const headers = new Headers(request.headers)
headers.delete('host')
headers.set('accept-encoding', UPSTREAM_ACCEPT_ENCODING)
if (headers.has('origin'))
headers.set('origin', targetUrl.origin)
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined)
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, {
useHostPrefix: targetUrl.protocol === 'https:',
})
if (rewrittenCookieHeader)
headers.set('cookie', rewrittenCookieHeader)
@ -137,7 +164,7 @@ const proxyRequest = async (
const registerProxyRoute = (
app: Hono,
path: '/console/api' | '/api',
path: ProxyRoutePath,
target: string,
fetchImpl: typeof globalThis.fetch,
) => {
@ -145,15 +172,26 @@ const registerProxyRoute = (
app.all(`${path}/*`, context => proxyRequest(context, target, fetchImpl))
}
const registerProxyRoutes = (
app: Hono,
routes: readonly ProxyRoutePath[],
target: string,
fetchImpl: typeof globalThis.fetch,
) => {
routes.forEach(route => registerProxyRoute(app, route, target, fetchImpl))
}
export const resolveDevProxyTargets = (env: DevProxyEnv = {}): DevProxyTargets => {
const consoleApiTarget = env.HONO_CONSOLE_API_PROXY_TARGET
|| DEFAULT_PROXY_TARGET
const publicApiTarget = env.HONO_PUBLIC_API_PROXY_TARGET
|| consoleApiTarget
const enterpriseApiTarget = env.HONO_ENTERPRISE_API_PROXY_TARGET
return {
consoleApiTarget,
publicApiTarget,
enterpriseApiTarget,
}
}
@ -195,8 +233,10 @@ export const createDevProxyApp = (options: DevProxyAppOptions) => {
applyCorsHeaders(context.res.headers, context.req.header('origin'))
})
registerProxyRoute(app, '/console/api', options.consoleApiTarget, fetchImpl)
registerProxyRoute(app, '/api', options.publicApiTarget, fetchImpl)
if (options.enterpriseApiTarget)
registerProxyRoutes(app, ENTERPRISE_API_ROUTES, options.enterpriseApiTarget, fetchImpl)
registerProxyRoutes(app, CONSOLE_API_ROUTES, options.consoleApiTarget, fetchImpl)
registerProxyRoutes(app, PUBLIC_API_ROUTES, options.publicApiTarget, fetchImpl)
return app
}

View File

@ -2,14 +2,15 @@ import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { serve } from '@hono/node-server'
import { loadEnv } from 'vite'
import { parseDevProxyCliArgs, resolveDevProxyServerOptions } from '../plugins/dev-proxy/config'
import { createDevProxyApp, resolveDevProxyTargets } from '../plugins/dev-proxy/server'
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
const mode = process.env.MODE || process.env.NODE_ENV || 'development'
const env = loadEnv(mode, projectRoot, '')
const cliOptions = parseDevProxyCliArgs(process.argv.slice(2))
const { host, port, proxyTarget } = resolveDevProxyServerOptions(env, cliOptions)
const host = env.HONO_PROXY_HOST || '127.0.0.1'
const port = Number(env.HONO_PROXY_PORT || 5001)
const app = createDevProxyApp(resolveDevProxyTargets(env))
serve({
@ -18,4 +19,4 @@ serve({
port,
})
console.log(`[dev-hono-proxy] listening on http://${host}:${port}`)
console.log(`[dev-hono-proxy] target=${proxyTarget} listening on http://${host}:${port}`)