mirror of
https://github.com/langgenius/dify.git
synced 2026-10-29 18:07:39 +08:00
feat(dev-proxy): init package (#35852)
This commit is contained in:
+5
-12
@@ -17,18 +17,11 @@ NEXT_PUBLIC_COOKIE_DOMAIN=
|
||||
# WebSocket server URL.
|
||||
NEXT_PUBLIC_SOCKET_URL=ws://localhost:5001
|
||||
|
||||
# Dev-only Hono proxy targets.
|
||||
# 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=
|
||||
HONO_CONSOLE_API_PROXY_TARGET=
|
||||
HONO_PUBLIC_API_PROXY_TARGET=
|
||||
HONO_ENTERPRISE_API_PROXY_TARGET=
|
||||
# Dev proxy routes are configured in web/dev-proxy.config.ts.
|
||||
# pnpm -C web run dev:proxy loads web/.env.local before evaluating that config file.
|
||||
DEV_PROXY_TARGET=https://cloud.dify.ai
|
||||
DEV_PROXY_HOST=127.0.0.1
|
||||
DEV_PROXY_PORT=5001
|
||||
|
||||
# The API PREFIX for MARKETPLACE
|
||||
NEXT_PUBLIC_MARKETPLACE_API_PREFIX=https://marketplace.dify.ai/api/v1
|
||||
|
||||
+2
-2
@@ -56,9 +56,9 @@ pnpm -C web run dev
|
||||
# or if you are using vinext which provides a better development experience
|
||||
pnpm -C web run dev:vinext
|
||||
# (optional) start the dev proxy server so that you can use online API in development
|
||||
# edit web/dev-proxy.config.ts to choose proxy paths
|
||||
# edit web/.env.local to override DEV_PROXY_TARGET, DEV_PROXY_ENTERPRISE_TARGET, DEV_PROXY_HOST, or DEV_PROXY_PORT
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { CookieRewriteOptions, DevProxyConfig } from '@langgenius/dev-proxy'
|
||||
|
||||
const DIFY_CLOUD_TARGET = 'https://cloud.dify.ai'
|
||||
const DEV_PROXY_TARGET = process.env.DEV_PROXY_TARGET || DIFY_CLOUD_TARGET
|
||||
const DEV_PROXY_ENTERPRISE_TARGET = process.env.DEV_PROXY_ENTERPRISE_TARGET || DEV_PROXY_TARGET
|
||||
const DEV_PROXY_HOST = process.env.DEV_PROXY_HOST || '127.0.0.1'
|
||||
const DEV_PROXY_PORT = Number(process.env.DEV_PROXY_PORT || 5001)
|
||||
|
||||
const difyCookieRewrite: CookieRewriteOptions = {
|
||||
hostPrefixCookies: [
|
||||
'access_token',
|
||||
'csrf_token',
|
||||
'refresh_token',
|
||||
'webapp_access_token',
|
||||
/^passport-/,
|
||||
],
|
||||
}
|
||||
|
||||
export default {
|
||||
server: {
|
||||
host: DEV_PROXY_HOST,
|
||||
port: DEV_PROXY_PORT,
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
paths: [
|
||||
'/console/api/enterprise',
|
||||
'/api/enterprise',
|
||||
'/admin-api',
|
||||
'/inner/api',
|
||||
'/mfa',
|
||||
'/scim',
|
||||
'/v1/audit',
|
||||
'/v1/dashboard',
|
||||
'/v1/healthz',
|
||||
'/v1/plugin-manager',
|
||||
],
|
||||
target: DEV_PROXY_ENTERPRISE_TARGET,
|
||||
cookieRewrite: difyCookieRewrite,
|
||||
},
|
||||
{
|
||||
paths: [
|
||||
'/console/api',
|
||||
'/api',
|
||||
],
|
||||
target: DEV_PROXY_TARGET,
|
||||
cookieRewrite: difyCookieRewrite,
|
||||
},
|
||||
],
|
||||
} satisfies DevProxyConfig
|
||||
@@ -8,6 +8,7 @@ const config: KnipConfig = {
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'bin/**/*.{js,ts,mjs}',
|
||||
'tsslint.config.ts',
|
||||
'dev-proxy.config.ts',
|
||||
],
|
||||
ignore: [
|
||||
'public/**',
|
||||
|
||||
+2
-3
@@ -28,7 +28,7 @@
|
||||
"build:vinext": "vinext build",
|
||||
"dev": "next dev",
|
||||
"dev:inspect": "next dev --inspect",
|
||||
"dev:proxy": "tsx ./scripts/dev-hono-proxy.ts",
|
||||
"dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local",
|
||||
"dev:vinext": "vinext dev",
|
||||
"gen-doc-paths": "tsx ./scripts/gen-doc-paths.ts",
|
||||
"gen-icons": "pnpm --filter @dify/iconify-collections generate && node ./scripts/gen-icons.mjs && eslint --fix app/components/base/icons/src/",
|
||||
@@ -160,9 +160,9 @@
|
||||
"@dify/tsconfig": "workspace:*",
|
||||
"@egoist/tailwindcss-icons": "catalog:",
|
||||
"@eslint-react/eslint-plugin": "catalog:",
|
||||
"@hono/node-server": "catalog:",
|
||||
"@iconify-json/heroicons": "catalog:",
|
||||
"@iconify-json/ri": "catalog:",
|
||||
"@langgenius/dev-proxy": "workspace:*",
|
||||
"@langgenius/dify-ui": "workspace:*",
|
||||
"@mdx-js/loader": "catalog:",
|
||||
"@mdx-js/react": "catalog:",
|
||||
@@ -214,7 +214,6 @@
|
||||
"eslint-plugin-sonarjs": "catalog:",
|
||||
"eslint-plugin-storybook": "catalog:",
|
||||
"happy-dom": "catalog:",
|
||||
"hono": "catalog:",
|
||||
"knip": "catalog:",
|
||||
"postcss": "catalog:",
|
||||
"react-server-dom-webpack": "catalog:",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
/**
|
||||
* @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')
|
||||
})
|
||||
})
|
||||
@@ -1,103 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
const DEFAULT_PROXY_TARGET = 'https://cloud.dify.ai'
|
||||
|
||||
const SECURE_COOKIE_PREFIX_PATTERN = /^__(Host|Secure)-/
|
||||
const SAME_SITE_NONE_PATTERN = /^samesite=none$/i
|
||||
const COOKIE_PATH_PATTERN = /^path=/i
|
||||
const COOKIE_DOMAIN_PATTERN = /^domain=/i
|
||||
const COOKIE_SECURE_PATTERN = /^secure$/i
|
||||
const COOKIE_PARTITIONED_PATTERN = /^partitioned$/i
|
||||
|
||||
const HOST_PREFIX_COOKIE_NAMES = new Set([
|
||||
'access_token',
|
||||
'csrf_token',
|
||||
'refresh_token',
|
||||
'webapp_access_token',
|
||||
])
|
||||
|
||||
const isPassportCookie = (cookieName: string) => cookieName.startsWith('passport-')
|
||||
|
||||
const shouldUseHostPrefix = (cookieName: string) => {
|
||||
const normalizedCookieName = cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
|
||||
return HOST_PREFIX_COOKIE_NAMES.has(normalizedCookieName) || isPassportCookie(normalizedCookieName)
|
||||
}
|
||||
|
||||
const toUpstreamCookieName = (cookieName: string) => {
|
||||
if (cookieName.startsWith('__Host-'))
|
||||
return cookieName
|
||||
|
||||
if (cookieName.startsWith('__Secure-'))
|
||||
return `__Host-${cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')}`
|
||||
|
||||
if (!shouldUseHostPrefix(cookieName))
|
||||
return cookieName
|
||||
|
||||
return `__Host-${cookieName}`
|
||||
}
|
||||
|
||||
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
|
||||
|
||||
export const rewriteCookieHeaderForUpstream = (
|
||||
cookieHeader?: string,
|
||||
options: { useHostPrefix?: boolean } = {},
|
||||
) => {
|
||||
if (!cookieHeader)
|
||||
return cookieHeader
|
||||
|
||||
const { useHostPrefix = true } = options
|
||||
|
||||
return cookieHeader
|
||||
.split(/;\s*/)
|
||||
.filter(Boolean)
|
||||
.map((cookie) => {
|
||||
const separatorIndex = cookie.indexOf('=')
|
||||
if (separatorIndex === -1)
|
||||
return cookie
|
||||
|
||||
const cookieName = cookie.slice(0, separatorIndex).trim()
|
||||
const cookieValue = cookie.slice(separatorIndex + 1)
|
||||
return `${useHostPrefix ? toUpstreamCookieName(cookieName) : cookieName}=${cookieValue}`
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
|
||||
const [rawCookiePair, ...rawAttributes] = setCookieValue.split(';')
|
||||
const separatorIndex = rawCookiePair!.indexOf('=')
|
||||
|
||||
if (separatorIndex === -1)
|
||||
return setCookieValue
|
||||
|
||||
const cookieName = rawCookiePair!.slice(0, separatorIndex).trim()
|
||||
const cookieValue = rawCookiePair!.slice(separatorIndex + 1)
|
||||
const rewrittenAttributes = rawAttributes
|
||||
.map(attribute => attribute.trim())
|
||||
.filter(attribute =>
|
||||
!COOKIE_DOMAIN_PATTERN.test(attribute)
|
||||
&& !COOKIE_SECURE_PATTERN.test(attribute)
|
||||
&& !COOKIE_PARTITIONED_PATTERN.test(attribute),
|
||||
)
|
||||
.map((attribute) => {
|
||||
if (SAME_SITE_NONE_PATTERN.test(attribute))
|
||||
return 'SameSite=Lax'
|
||||
|
||||
if (COOKIE_PATH_PATTERN.test(attribute))
|
||||
return 'Path=/'
|
||||
|
||||
return attribute
|
||||
})
|
||||
|
||||
return [`${toLocalCookieName(cookieName)}=${cookieValue}`, ...rewrittenAttributes].join('; ')
|
||||
}
|
||||
|
||||
export const rewriteSetCookieHeadersForLocal = (setCookieHeaders?: string | string[]): string[] | undefined => {
|
||||
if (!setCookieHeaders)
|
||||
return undefined
|
||||
|
||||
const normalizedHeaders = Array.isArray(setCookieHeaders)
|
||||
? setCookieHeaders
|
||||
: [setCookieHeaders]
|
||||
|
||||
return normalizedHeaders.map(rewriteSetCookieValueForLocal)
|
||||
}
|
||||
|
||||
export { DEFAULT_PROXY_TARGET }
|
||||
@@ -1,207 +0,0 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets } from './server'
|
||||
|
||||
describe('dev proxy server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// Scenario: Hono proxy targets should be read directly from env.
|
||||
it('should resolve Hono proxy targets from env', () => {
|
||||
// Arrange
|
||||
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.
|
||||
it('should preserve prefixed targets when building upstream URLs', () => {
|
||||
// Act
|
||||
const url = buildUpstreamUrl('https://api.example.com/console/api', '/console/api/apps', '?page=1')
|
||||
|
||||
// Assert
|
||||
expect(url.href).toBe('https://api.example.com/console/api/apps?page=1')
|
||||
})
|
||||
|
||||
// Scenario: only localhost dev origins should be reflected for credentialed CORS.
|
||||
it('should only allow local development origins', () => {
|
||||
// Assert
|
||||
expect(isAllowedDevOrigin('http://localhost:3000')).toBe(true)
|
||||
expect(isAllowedDevOrigin('http://127.0.0.1:3000')).toBe(true)
|
||||
expect(isAllowedDevOrigin('https://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
// Scenario: proxy requests should rewrite cookies and surface credentialed CORS headers.
|
||||
it('should proxy api requests through Hono with local cookie rewriting', async () => {
|
||||
// Arrange
|
||||
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok', {
|
||||
status: 200,
|
||||
headers: [
|
||||
['content-encoding', 'br'],
|
||||
['content-length', '123'],
|
||||
['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.dify.ai; Secure; SameSite=None'],
|
||||
['transfer-encoding', 'chunked'],
|
||||
],
|
||||
}))
|
||||
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',
|
||||
'Accept-Encoding': 'zstd, br, gzip',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(fetchImpl).toHaveBeenCalledTimes(1)
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
new URL('https://cloud.dify.ai/console/api/apps?page=1'),
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
headers: expect.any(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()
|
||||
expect(response.headers.get('content-length')).toBeNull()
|
||||
expect(response.headers.get('transfer-encoding')).toBeNull()
|
||||
expect(response.headers.getSetCookie()).toEqual([
|
||||
'access_token=abc; Path=/; SameSite=Lax',
|
||||
])
|
||||
})
|
||||
|
||||
// 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>(),
|
||||
})
|
||||
|
||||
// Act
|
||||
const response = await app.request('http://127.0.0.1:5001/api/messages', {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Origin': 'http://localhost:3000',
|
||||
'Access-Control-Request-Headers': 'authorization,content-type,x-csrf-token',
|
||||
},
|
||||
})
|
||||
|
||||
// Assert
|
||||
expect(response.status).toBe(204)
|
||||
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('access-control-allow-headers')).toBe('authorization,content-type,x-csrf-token')
|
||||
})
|
||||
})
|
||||
@@ -1,242 +0,0 @@
|
||||
import type { Context, Hono } from 'hono'
|
||||
import { Hono as HonoApp } from 'hono'
|
||||
import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
|
||||
|
||||
type DevProxyEnv = Partial<Record<
|
||||
| 'HONO_CONSOLE_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 & {
|
||||
fetchImpl?: typeof globalThis.fetch
|
||||
}
|
||||
|
||||
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',
|
||||
'content-length',
|
||||
'keep-alive',
|
||||
'set-cookie',
|
||||
'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) {
|
||||
headers.set(name, value)
|
||||
return
|
||||
}
|
||||
|
||||
if (currentValue.split(',').map(item => item.trim()).includes(value))
|
||||
return
|
||||
|
||||
headers.set(name, `${currentValue}, ${value}`)
|
||||
}
|
||||
|
||||
export const isAllowedDevOrigin = (origin?: string | null) => {
|
||||
if (!origin)
|
||||
return false
|
||||
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
return LOCAL_DEV_HOSTS.has(url.hostname)
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const applyCorsHeaders = (headers: Headers, origin?: string | null) => {
|
||||
if (!isAllowedDevOrigin(origin))
|
||||
return
|
||||
|
||||
headers.set('Access-Control-Allow-Origin', origin!)
|
||||
headers.set('Access-Control-Allow-Credentials', 'true')
|
||||
appendHeaderValue(headers, 'Vary', 'Origin')
|
||||
}
|
||||
|
||||
export const buildUpstreamUrl = (target: string, requestPath: string, search = '') => {
|
||||
const targetUrl = new URL(target)
|
||||
const normalizedTargetPath = targetUrl.pathname === '/' ? '' : targetUrl.pathname.replace(/\/$/, '')
|
||||
const normalizedRequestPath = requestPath.startsWith('/') ? requestPath : `/${requestPath}`
|
||||
const hasTargetPrefix = normalizedTargetPath
|
||||
&& (normalizedRequestPath === normalizedTargetPath || normalizedRequestPath.startsWith(`${normalizedTargetPath}/`))
|
||||
|
||||
targetUrl.pathname = hasTargetPrefix
|
||||
? normalizedRequestPath
|
||||
: `${normalizedTargetPath}${normalizedRequestPath}`
|
||||
targetUrl.search = search
|
||||
|
||||
return targetUrl
|
||||
}
|
||||
|
||||
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, {
|
||||
useHostPrefix: targetUrl.protocol === 'https:',
|
||||
})
|
||||
if (rewrittenCookieHeader)
|
||||
headers.set('cookie', rewrittenCookieHeader)
|
||||
|
||||
return headers
|
||||
}
|
||||
|
||||
const createUpstreamResponseHeaders = (response: Response, requestOrigin?: string | null) => {
|
||||
const headers = new Headers(response.headers)
|
||||
RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header))
|
||||
|
||||
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie())
|
||||
rewrittenSetCookies?.forEach((cookie) => {
|
||||
headers.append('set-cookie', cookie)
|
||||
})
|
||||
|
||||
applyCorsHeaders(headers, requestOrigin)
|
||||
return headers
|
||||
}
|
||||
|
||||
const proxyRequest = async (
|
||||
context: Context,
|
||||
target: string,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
) => {
|
||||
const requestUrl = new URL(context.req.url)
|
||||
const targetUrl = buildUpstreamUrl(target, requestUrl.pathname, requestUrl.search)
|
||||
const requestHeaders = createProxyRequestHeaders(context.req.raw, targetUrl)
|
||||
const requestInit: RequestInit & { duplex?: 'half' } = {
|
||||
method: context.req.method,
|
||||
headers: requestHeaders,
|
||||
redirect: 'manual',
|
||||
}
|
||||
|
||||
if (context.req.method !== 'GET' && context.req.method !== 'HEAD') {
|
||||
requestInit.body = context.req.raw.body
|
||||
requestInit.duplex = 'half'
|
||||
}
|
||||
|
||||
const upstreamResponse = await fetchImpl(targetUrl, requestInit)
|
||||
const responseHeaders = createUpstreamResponseHeaders(upstreamResponse, context.req.header('origin'))
|
||||
|
||||
return new Response(upstreamResponse.body, {
|
||||
status: upstreamResponse.status,
|
||||
statusText: upstreamResponse.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
}
|
||||
|
||||
const registerProxyRoute = (
|
||||
app: Hono,
|
||||
path: ProxyRoutePath,
|
||||
target: string,
|
||||
fetchImpl: typeof globalThis.fetch,
|
||||
) => {
|
||||
app.all(path, context => proxyRequest(context, target, fetchImpl))
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export const createDevProxyApp = (options: DevProxyAppOptions) => {
|
||||
const app = new HonoApp()
|
||||
const fetchImpl = options.fetchImpl || globalThis.fetch
|
||||
|
||||
app.onError((error, context) => {
|
||||
console.error('[dev-hono-proxy]', error)
|
||||
|
||||
const headers = new Headers()
|
||||
applyCorsHeaders(headers, context.req.header('origin'))
|
||||
|
||||
return new Response('Upstream proxy request failed.', {
|
||||
status: 502,
|
||||
headers,
|
||||
})
|
||||
})
|
||||
|
||||
app.use('*', async (context, next) => {
|
||||
if (context.req.method === 'OPTIONS') {
|
||||
const headers = new Headers()
|
||||
applyCorsHeaders(headers, context.req.header('origin'))
|
||||
headers.set('Access-Control-Allow-Methods', ALLOW_METHODS)
|
||||
headers.set(
|
||||
'Access-Control-Allow-Headers',
|
||||
context.req.header('Access-Control-Request-Headers') || DEFAULT_ALLOW_HEADERS,
|
||||
)
|
||||
if (context.req.header('Access-Control-Request-Private-Network') === 'true')
|
||||
headers.set('Access-Control-Allow-Private-Network', 'true')
|
||||
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
await next()
|
||||
applyCorsHeaders(context.res.headers, context.req.header('origin'))
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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 app = createDevProxyApp(resolveDevProxyTargets(env))
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
|
||||
console.log(`[dev-hono-proxy] target=${proxyTarget} listening on http://${host}:${port}`)
|
||||
Reference in New Issue
Block a user