diff --git a/web/.env.example b/web/.env.example index 643aba482e..1f3dc23b77 100644 --- a/web/.env.example +++ b/web/.env.example @@ -21,10 +21,15 @@ 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= +# Defaults to https://enterprise-platform-dev.dify.dev when empty. +HONO_ENTERPRISE_API_PROXY_TARGET= # The API PREFIX for MARKETPLACE NEXT_PUBLIC_MARKETPLACE_API_PREFIX=https://marketplace.dify.ai/api/v1 diff --git a/web/README.md b/web/README.md index eb964b01e3..206541eab6 100644 --- a/web/README.md +++ b/web/README.md @@ -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 with your browser to see the result. diff --git a/web/plugins/dev-proxy/config.spec.ts b/web/plugins/dev-proxy/config.spec.ts new file mode 100644 index 0000000000..442bedcb12 --- /dev/null +++ b/web/plugins/dev-proxy/config.spec.ts @@ -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') + }) +}) diff --git a/web/plugins/dev-proxy/config.ts b/web/plugins/dev-proxy/config.ts new file mode 100644 index 0000000000..7350d20fc5 --- /dev/null +++ b/web/plugins/dev-proxy/config.ts @@ -0,0 +1,103 @@ +export const DEV_PROXY_TARGETS = ['dify', 'enterprise'] as const + +export type DevProxyTarget = typeof DEV_PROXY_TARGETS[number] + +type DevProxyConfigEnv = Partial> + +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 = { + 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, + } +} diff --git a/web/plugins/dev-proxy/server.spec.ts b/web/plugins/dev-proxy/server.spec.ts index d9c9ee22ba..eb5c66d614 100644 --- a/web/plugins/dev-proxy/server.spec.ts +++ b/web/plugins/dev-proxy/server.spec.ts @@ -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).toBe('https://enterprise-platform-dev.dify.dev') }) // 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,57 @@ describe('dev proxy server', () => { ]) }) + // 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().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(), }) diff --git a/web/plugins/dev-proxy/server.ts b/web/plugins/dev-proxy/server.ts index a3752784fa..42cfa10190 100644 --- a/web/plugins/dev-proxy/server.ts +++ b/web/plugins/dev-proxy/server.ts @@ -2,15 +2,19 @@ import type { Context, Hono } from 'hono' import { Hono as HonoApp } from 'hono' import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies' +const DEFAULT_ENTERPRISE_PROXY_TARGET = 'https://enterprise-platform-dev.dify.dev' + type DevProxyEnv = Partial> type DevProxyTargets = { consoleApiTarget: string publicApiTarget: string + enterpriseApiTarget: string } type DevProxyAppOptions = DevProxyTargets & { @@ -20,6 +24,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 +34,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,6 +108,7 @@ 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) @@ -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,27 @@ 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 + || DEFAULT_ENTERPRISE_PROXY_TARGET return { consoleApiTarget, publicApiTarget, + enterpriseApiTarget, } } @@ -195,8 +234,9 @@ 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) + 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 } diff --git a/web/scripts/dev-hono-proxy.ts b/web/scripts/dev-hono-proxy.ts index f550d47ae8..10d71c5ad7 100644 --- a/web/scripts/dev-hono-proxy.ts +++ b/web/scripts/dev-hono-proxy.ts @@ -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}`)