feat(dev-proxy): init package (#35852)

This commit is contained in:
Stephen Zhou
2026-05-07 11:32:14 +08:00
committed by GitHub
parent 376c43e5ac
commit e5bdc40dce
25 changed files with 1380 additions and 432 deletions

View File

@@ -0,0 +1,196 @@
# @langgenius/dev-proxy
Generic Hono-based development proxy for frontend projects. The package does not ship any product-specific routes, cookie names, or environment variable conventions. Every proxied path and upstream target is declared in a local config file.
## Installation
```bash
pnpm add -D @langgenius/dev-proxy
```
Add a script in your frontend project:
```json
{
"scripts": {
"dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env"
}
}
```
Run it with:
```bash
pnpm dev:proxy
```
## CLI
```bash
dev-proxy --config ./dev-proxy.config.ts
```
Supported options:
- `--config`, `-c`: config file path. Defaults to `dev-proxy.config.ts`.
- `--env-file`: load environment variables before evaluating the config file.
- `--host`: override `server.host` from config.
- `--port`: override `server.port` from config.
- `--help`, `-h`: print help.
`--target` is not supported. Put targets in the config file so routes and upstreams stay explicit.
## Config Shape
```ts
import { defineDevProxyConfig } from '@langgenius/dev-proxy'
export default defineDevProxyConfig({
server: {
host: '127.0.0.1',
port: 5001,
},
routes: [
{
paths: '/api',
target: 'https://example.com',
},
],
cors: {
allowedOrigins: 'local',
},
})
```
Config files can be `.ts`, `.mts`, `.js`, or `.mjs`.
`routes` are matched in declaration order. The first matching route wins. Each configured path matches both the exact path and all child paths, so `paths: '/api'` matches `/api`, `/api/apps`, and `/api/apps/123`.
By default, credentialed CORS is allowed for local development origins such as `localhost`, `127.0.0.1`, and `::1`. To restrict it to specific origins:
```
cors: {
allowedOrigins: ['http://localhost:3000'],
}
```
## Scenario 1: Proxy One Local Route Group To An Online Backend
Use this when a local frontend should call an online backend through one proxy server. For example, the frontend calls `http://127.0.0.1:5001/api/apps`, and the proxy forwards it to `https://cloud.example.com/api/apps`.
```ts
import { defineDevProxyConfig } from '@langgenius/dev-proxy'
const target = process.env.DEV_PROXY_TARGET || 'https://cloud.example.com'
export default defineDevProxyConfig({
server: {
host: process.env.DEV_PROXY_HOST || '127.0.0.1',
port: Number(process.env.DEV_PROXY_PORT || 5001),
},
routes: [
{
paths: '/api',
target,
},
],
})
```
Optional `.env`:
```env
DEV_PROXY_TARGET=https://cloud.example.com
DEV_PROXY_HOST=127.0.0.1
DEV_PROXY_PORT=5001
```
Command:
```bash
dev-proxy --config ./dev-proxy.config.ts --env-file ./.env
```
## Scenario 2: Proxy Two Route Groups To Two Local Backends
Use this when one frontend needs to talk to two different local services. For example:
- `/console/api/*` goes to a local console backend at `http://127.0.0.1:5001`
- `/api/*` goes to a local public API backend at `http://127.0.0.1:5002`
```ts
import { defineDevProxyConfig } from '@langgenius/dev-proxy'
const consoleApiTarget = process.env.DEV_PROXY_CONSOLE_API_TARGET || 'http://127.0.0.1:5001'
const publicApiTarget = process.env.DEV_PROXY_PUBLIC_API_TARGET || 'http://127.0.0.1:5002'
export default defineDevProxyConfig({
server: {
host: process.env.DEV_PROXY_HOST || '127.0.0.1',
port: Number(process.env.DEV_PROXY_PORT || 8082),
},
routes: [
{
paths: '/console/api',
target: consoleApiTarget,
},
{
paths: '/api',
target: publicApiTarget,
},
],
})
```
Optional `.env`:
```env
DEV_PROXY_CONSOLE_API_TARGET=http://127.0.0.1:5001
DEV_PROXY_PUBLIC_API_TARGET=http://127.0.0.1:5002
DEV_PROXY_HOST=127.0.0.1
DEV_PROXY_PORT=8082
```
When two route groups overlap, put the more specific one first:
```ts
routes: [
{ paths: '/api/enterprise', target: 'http://127.0.0.1:5003' },
{ paths: '/api', target: 'http://127.0.0.1:5002' },
]
```
## Cookie Rewrite
Cookie rewriting is opt-in and config-driven. The package does not know any application cookie names.
Use `cookieRewrite` when an upstream uses secure cookie prefixes such as `__Host-` or `__Secure-`, but local development needs cookies to work over `http://localhost`.
```ts
import type { CookieRewriteOptions } from '@langgenius/dev-proxy'
import { defineDevProxyConfig } from '@langgenius/dev-proxy'
const cookieRewrite: CookieRewriteOptions = {
hostPrefixCookies: ['access_token', 'refresh_token', /^passport-/],
}
export default defineDevProxyConfig({
routes: [
{
paths: '/api',
target: 'https://cloud.example.com',
cookieRewrite,
},
],
})
```
Set `cookieRewrite: false` to disable cookie rewriting for a route.
## Behavior
- The proxy preserves the matched path prefix when forwarding requests.
- Request bodies are forwarded as streams.
- Hop-by-hop headers are removed before forwarding.
- Local credentialed CORS and preflight requests are handled by the proxy.
- Route matching is explicit and order-sensitive.

View File

@@ -0,0 +1,3 @@
#!/usr/bin/env node
import '../dist/cli.mjs'

View File

@@ -0,0 +1,43 @@
{
"name": "@langgenius/dev-proxy",
"type": "module",
"version": "0.0.5",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"types": "./dist/index.d.mts",
"bin": {
"dev-proxy": "./bin/dev-proxy.js"
},
"files": [
"bin",
"dist",
"src"
],
"engines": {
"node": "^22.22.1"
},
"scripts": {
"build": "vp pack",
"prepare": "pnpm run build",
"test": "vp test",
"type-check": "tsgo",
"prepublish": "pnpm run build"
},
"dependencies": {
"@hono/node-server": "catalog:",
"c12": "catalog:",
"hono": "catalog:"
},
"devDependencies": {
"@dify/tsconfig": "workspace:*",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
}
}

View File

@@ -0,0 +1,158 @@
/**
* @vitest-environment node
*/
import type { ChildProcessByStdio } from 'node:child_process'
import type { Readable } from 'node:stream'
import { spawn } from 'node:child_process'
import { once } from 'node:events'
import fs from 'node:fs/promises'
import net from 'node:net'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const tempDirs: string[] = []
type DevProxyCliProcess = ChildProcessByStdio<null, Readable, Readable>
const childProcesses: DevProxyCliProcess[] = []
const binPath = fileURLToPath(new URL('../bin/dev-proxy.js', import.meta.url))
const createTempDir = async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-proxy-cli-test-'))
tempDirs.push(tempDir)
return tempDir
}
const getFreePort = async () => {
const server = net.createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
if (!address || typeof address === 'string')
throw new Error('Failed to allocate a test port.')
const { port } = address
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error)
reject(error)
else
resolve()
})
})
return port
}
const waitForOutput = (
child: DevProxyCliProcess,
output: () => string,
expectedOutput: string,
) => new Promise<void>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout>
function cleanup() {
clearTimeout(timeout)
child.stdout.off('data', onData)
child.stderr.off('data', onData)
child.off('exit', onExit)
}
function onData() {
if (!output().includes(expectedOutput))
return
cleanup()
resolve()
}
function onExit(code: number | null, signal: NodeJS.Signals | null) {
cleanup()
reject(new Error(`dev-proxy exited before writing "${expectedOutput}" with code ${code} and signal ${signal}. Output:\n${output()}`))
}
timeout = setTimeout(() => {
cleanup()
reject(new Error(`Timed out waiting for "${expectedOutput}". Output:\n${output()}`))
}, 3000)
child.stdout.on('data', onData)
child.stderr.on('data', onData)
child.once('exit', onExit)
onData()
})
const spawnCli = (args: readonly string[], cwd: string) => {
const child = spawn(process.execPath, [binPath, ...args], {
cwd,
env: {
...process.env,
FORCE_COLOR: '0',
},
stdio: ['ignore', 'pipe', 'pipe'],
})
childProcesses.push(child)
return child
}
const stopChildProcess = async (child: DevProxyCliProcess) => {
if (child.exitCode !== null || child.signalCode !== null)
return
child.kill('SIGTERM')
await once(child, 'exit')
}
describe('dev proxy CLI', () => {
afterEach(async () => {
await Promise.all(childProcesses.splice(0).map(stopChildProcess))
await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, {
force: true,
recursive: true,
})))
})
// Scenario: help output should still be a normal short-lived command.
it('should print help and exit', async () => {
// Arrange
const tempDir = await createTempDir()
const child = spawnCli(['--help'], tempDir)
// Act
const [code] = await once(child, 'exit')
// Assert
expect(code).toBe(0)
})
// Scenario: successful server startup should keep the CLI process alive.
it('should keep running after starting the proxy server', async () => {
// Arrange
const tempDir = await createTempDir()
const port = await getFreePort()
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
export default {
routes: [{ paths: '/api', target: 'https://api.example.com' }],
}
`)
let output = ''
const child = spawnCli(['--config', './dev-proxy.config.ts', '--host', '127.0.0.1', '--port', String(port)], tempDir)
child.stdout.on('data', chunk => output += chunk.toString())
child.stderr.on('data', chunk => output += chunk.toString())
// Act
await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`)
await new Promise(resolve => setTimeout(resolve, 100))
const response = await fetch(`http://127.0.0.1:${port}/not-proxied`)
// Assert
expect(child.exitCode).toBeNull()
expect(child.signalCode).toBeNull()
expect(response.status).toBe(404)
})
})

View File

@@ -0,0 +1,56 @@
import process from 'node:process'
import { serve } from '@hono/node-server'
import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config'
import { createDevProxyApp } from './server'
function printUsage() {
console.log(`Usage:
dev-proxy --config <path> [options]
Options:
--config, -c <path> Path to a dev proxy config file. Defaults to dev-proxy.config.ts.
--env-file <path> Load environment variables before evaluating the config file.
--host <host> Override the configured host.
--port <port> Override the configured port.
--help, -h Show this help message.`)
}
async function flushStandardStreams() {
await Promise.all([
new Promise<void>(resolve => process.stdout.write('', () => resolve())),
new Promise<void>(resolve => process.stderr.write('', () => resolve())),
])
}
async function main() {
const cliOptions = parseDevProxyCliArgs(process.argv.slice(2))
if (cliOptions.help) {
printUsage()
return
}
const config = await loadDevProxyConfig(cliOptions.config, process.cwd(), {
envFile: cliOptions.envFile,
})
const { host, port } = resolveDevProxyServerOptions(config.server, cliOptions)
const app = createDevProxyApp(config)
serve({
fetch: app.fetch,
hostname: host,
port,
})
console.log(`[dev-proxy] listening on http://${host}:${port}`)
}
try {
await main()
await flushStandardStreams()
}
catch (error) {
console.error(error instanceof Error ? error.message : error)
await flushStandardStreams()
process.exit(1)
}

View File

@@ -0,0 +1,145 @@
/**
* @vitest-environment node
*/
import fs from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config'
const tempDirs: string[] = []
const createTempDir = async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'dev-proxy-test-'))
tempDirs.push(tempDir)
return tempDir
}
describe('dev proxy config', () => {
afterEach(async () => {
delete process.env.DEV_PROXY_TEST_PORT
delete process.env.DEV_PROXY_TEST_TARGET
await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, {
force: true,
recursive: true,
})))
})
// Scenario: CLI options should support both inline and separated values.
it('should parse proxy CLI options', () => {
// Act
const options = parseDevProxyCliArgs([
'--config=./dev-proxy.config.ts',
'--env-file',
'./.env.proxy',
'--host',
'0.0.0.0',
'--port',
'8083',
])
// Assert
expect(options).toEqual({
config: './dev-proxy.config.ts',
envFile: './.env.proxy',
host: '0.0.0.0',
port: '8083',
})
})
// Scenario: removed target shortcuts should fail instead of silently doing the wrong thing.
it('should reject unsupported target shortcuts', () => {
// Assert
expect(() => parseDevProxyCliArgs(['--target', 'enterprise'])).toThrow('Unsupported dev proxy option')
})
// Scenario: package manager argument separators should not be treated as proxy options.
it('should ignore package manager argument separators', () => {
// Act
const options = parseDevProxyCliArgs(['--config', './dev-proxy.config.ts', '--', '--help'])
// Assert
expect(options).toEqual({
config: './dev-proxy.config.ts',
help: true,
})
})
// Scenario: CLI host and port should override config defaults.
it('should resolve server options with CLI overrides', () => {
// Act
const options = resolveDevProxyServerOptions({
host: '127.0.0.1',
port: 5001,
}, {
host: '0.0.0.0',
port: '9002',
})
// Assert
expect(options).toEqual({
host: '0.0.0.0',
port: 9002,
})
})
// Scenario: TS config files should load through c12.
it('should load a TypeScript config file', async () => {
// Arrange
const tempDir = await createTempDir()
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
export default {
server: { host: '127.0.0.1', port: 7777 },
routes: [{ paths: ['/api', '/files'], target: 'https://api.example.com' }],
}
`)
// Act
const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir)
// Assert
expect(config.server).toEqual({
host: '127.0.0.1',
port: 7777,
})
expect(config.routes).toEqual([
{
paths: ['/api', '/files'],
target: 'https://api.example.com',
},
])
})
// Scenario: env files should be loaded before the TypeScript config is evaluated.
it('should load a TypeScript config file with env file values', async () => {
// Arrange
const tempDir = await createTempDir()
await fs.writeFile(path.join(tempDir, '.env.proxy'), [
'DEV_PROXY_TEST_PORT=7788',
'DEV_PROXY_TEST_TARGET=https://env.example.com',
].join('\n'))
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
export default {
server: { port: Number(process.env.DEV_PROXY_TEST_PORT) },
routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }],
}
`)
// Act
const config = await loadDevProxyConfig('dev-proxy.config.ts', tempDir, {
envFile: '.env.proxy',
})
// Assert
expect(config.server).toEqual({
port: 7788,
})
expect(config.routes).toEqual([
{
paths: '/api',
target: 'https://env.example.com',
},
])
})
})

View File

@@ -0,0 +1,129 @@
import type { DotenvOptions } from 'c12'
import type { DevProxyCliOptions, DevProxyConfig, DevProxyConfigLoadOptions, DevProxyServerConfig, ResolvedDevProxyServerOptions } from './types'
import path from 'node:path'
import { loadConfig } from 'c12'
const DEFAULT_CONFIG_FILE = 'dev-proxy.config.ts'
const DEFAULT_PROXY_HOST = '127.0.0.1'
const DEFAULT_PROXY_PORT = 5001
const OPTION_NAME_TO_KEY = {
'--config': 'config',
'-c': 'config',
'--env-file': 'envFile',
'--host': 'host',
'--port': 'port',
} 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 || value.startsWith('-'))
throw new Error(`Missing value for ${name}.`)
return value
}
export const parseDevProxyCliArgs = (argv: readonly string[]): DevProxyCliOptions => {
const options: DevProxyCliOptions = {}
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index]!
if (arg === '--')
continue
if (arg === '--help' || arg === '-h') {
options.help = true
continue
}
const [rawName, inlineValue] = arg.split('=', 2)
const name = rawName ?? ''
if (!name.startsWith('-'))
continue
if (!isOptionName(name))
throw new Error(`Unsupported dev proxy option "${name}".`)
const key = OPTION_NAME_TO_KEY[name]
options[key] = inlineValue ?? requireOptionValue(name, argv[index + 1])
if (inlineValue === undefined)
index += 1
}
return options
}
const resolvePort = (rawPort: string | number) => {
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 = (
serverConfig: DevProxyServerConfig = {},
cliOptions: DevProxyCliOptions = {},
): ResolvedDevProxyServerOptions => {
const configuredPort = cliOptions.port ?? serverConfig.port ?? DEFAULT_PROXY_PORT
return {
host: cliOptions.host || serverConfig.host || DEFAULT_PROXY_HOST,
port: resolvePort(configuredPort),
}
}
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null
export function assertDevProxyConfig(config: unknown): asserts config is DevProxyConfig {
if (!isRecord(config))
throw new Error('Dev proxy config must export an object.')
if (!Array.isArray(config.routes))
throw new Error('Dev proxy config must include a routes array.')
}
const resolveDotenvOptions = (
envFile: DevProxyConfigLoadOptions['envFile'],
cwd: string,
): DotenvOptions | false => {
if (!envFile)
return false
const resolvedEnvFilePath = path.resolve(cwd, envFile)
return {
cwd: path.dirname(resolvedEnvFilePath),
fileName: path.basename(resolvedEnvFilePath),
interpolate: true,
}
}
export const loadDevProxyConfig = async (
configPath = DEFAULT_CONFIG_FILE,
cwd = process.cwd(),
options: DevProxyConfigLoadOptions = {},
): Promise<DevProxyConfig> => {
const resolvedConfigPath = path.resolve(cwd, configPath)
const parsedPath = path.parse(resolvedConfigPath)
const { config: loadedConfig } = await loadConfig({
configFile: parsedPath.name,
cwd: parsedPath.dir,
dotenv: resolveDotenvOptions(options.envFile, cwd),
envName: false,
globalRc: false,
packageJson: false,
rcFile: false,
})
assertDevProxyConfig(loadedConfig)
return loadedConfig
}
export const defineDevProxyConfig = (config: DevProxyConfig) => config

View File

@@ -0,0 +1,44 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
describe('dev proxy cookies', () => {
// Scenario: cookie names should only receive secure host prefixes when configured.
it('should rewrite configured cookie names for HTTPS upstream requests', () => {
// Act
const cookieHeader = rewriteCookieHeaderForUpstream('access_token=abc; theme=dark; passport-app=def', {
hostPrefixCookies: ['access_token', /^passport-/],
useHostPrefix: true,
})
// Assert
expect(cookieHeader).toBe('__Host-access_token=abc; theme=dark; __Host-passport-app=def')
})
// Scenario: HTTP upstreams should keep local cookie names even when rewrite config exists.
it('should keep local cookie names for HTTP upstream requests', () => {
// Act
const cookieHeader = rewriteCookieHeaderForUpstream('access_token=abc; refresh_token=def', {
hostPrefixCookies: ['access_token', 'refresh_token'],
useHostPrefix: false,
})
// Assert
expect(cookieHeader).toBe('access_token=abc; refresh_token=def')
})
// Scenario: upstream set-cookie headers should be converted into localhost-safe cookies.
it('should rewrite upstream set-cookie headers for local development', () => {
// Act
const cookies = rewriteSetCookieHeadersForLocal([
'__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None; Partitioned',
])
// Assert
expect(cookies).toEqual([
'access_token=abc; Path=/; SameSite=Lax',
])
})
})

View File

@@ -1,4 +1,4 @@
const DEFAULT_PROXY_TARGET = 'https://cloud.dify.ai'
import type { CookieRewriteOptions } from './types'
const SECURE_COOKIE_PREFIX_PATTERN = /^__(Host|Secure)-/
const SAME_SITE_NONE_PATTERN = /^samesite=none$/i
@@ -7,38 +7,37 @@ 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 stripSecureCookiePrefix = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
const isPassportCookie = (cookieName: string) => cookieName.startsWith('passport-')
const matchesCookieName = (cookieName: string, matcher: string | RegExp) =>
typeof matcher === 'string'
? matcher === cookieName
: matcher.test(cookieName)
const shouldUseHostPrefix = (cookieName: string) => {
const normalizedCookieName = cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
return HOST_PREFIX_COOKIE_NAMES.has(normalizedCookieName) || isPassportCookie(normalizedCookieName)
const shouldUseHostPrefix = (cookieName: string, options: CookieRewriteOptions) => {
const normalizedCookieName = stripSecureCookiePrefix(cookieName)
return options.hostPrefixCookies?.some(matcher => matchesCookieName(normalizedCookieName, matcher)) || false
}
const toUpstreamCookieName = (cookieName: string) => {
const toUpstreamCookieName = (cookieName: string, options: CookieRewriteOptions) => {
if (cookieName.startsWith('__Host-'))
return cookieName
if (cookieName.startsWith('__Secure-'))
return `__Host-${cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')}`
return `__Host-${stripSecureCookiePrefix(cookieName)}`
if (!shouldUseHostPrefix(cookieName))
if (!shouldUseHostPrefix(cookieName, options))
return cookieName
return `__Host-${cookieName}`
}
const toLocalCookieName = (cookieName: string) => cookieName.replace(SECURE_COOKIE_PREFIX_PATTERN, '')
export const toLocalCookieName = (cookieName: string) => stripSecureCookiePrefix(cookieName)
export const rewriteCookieHeaderForUpstream = (
cookieHeader?: string,
options: { useHostPrefix?: boolean } = {},
cookieHeader: string | undefined,
options: CookieRewriteOptions & { useHostPrefix?: boolean },
) => {
if (!cookieHeader)
return cookieHeader
@@ -55,7 +54,11 @@ export const rewriteCookieHeaderForUpstream = (
const cookieName = cookie.slice(0, separatorIndex).trim()
const cookieValue = cookie.slice(separatorIndex + 1)
return `${useHostPrefix ? toUpstreamCookieName(cookieName) : cookieName}=${cookieValue}`
const upstreamCookieName = useHostPrefix
? toUpstreamCookieName(cookieName, options)
: cookieName
return `${upstreamCookieName}=${cookieValue}`
})
.join('; ')
}
@@ -89,15 +92,5 @@ const rewriteSetCookieValueForLocal = (setCookieValue: string) => {
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 }
export const rewriteSetCookieHeadersForLocal = (setCookieHeaders: readonly string[]) =>
setCookieHeaders.map(rewriteSetCookieValueForLocal)

View File

@@ -0,0 +1,22 @@
export {
assertDevProxyConfig,
defineDevProxyConfig,
loadDevProxyConfig,
parseDevProxyCliArgs,
resolveDevProxyServerOptions,
} from './config'
export { rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal, toLocalCookieName } from './cookies'
export { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, isAllowedLocalDevOrigin } from './server'
export type {
CookieNameMatcher,
CookieRewriteOptions,
CreateDevProxyAppOptions,
DevProxyCliOptions,
DevProxyConfig,
DevProxyConfigLoadOptions,
DevProxyCorsAllowedOrigins,
DevProxyCorsConfig,
DevProxyRoute,
DevProxyServerConfig,
ResolvedDevProxyServerOptions,
} from './types'

View File

@@ -2,41 +2,13 @@
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin, resolveDevProxyTargets } from './server'
import { buildUpstreamUrl, createDevProxyApp, isAllowedDevOrigin } 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
@@ -46,30 +18,43 @@ describe('dev proxy server', () => {
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', () => {
// Scenario: only localhost dev origins should be reflected for credentialed CORS by default.
it('should only allow local development origins by default', () => {
// 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 () => {
// Scenario: explicit CORS origins should support non-local development hosts.
it('should allow explicitly configured origins', () => {
// Assert
expect(isAllowedDevOrigin('https://app.example.com', ['https://app.example.com'])).toBe(true)
expect(isAllowedDevOrigin('https://other.example.com', ['https://app.example.com'])).toBe(false)
})
// Scenario: proxy requests should rewrite cookies and surface credentialed CORS headers when configured.
it('should proxy api requests with configured 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'],
['set-cookie', '__Host-access_token=abc; Path=/console/api; Domain=cloud.example.com; Secure; SameSite=None'],
['transfer-encoding', 'chunked'],
],
}))
const app = createDevProxyApp({
consoleApiTarget: 'https://cloud.dify.ai',
publicApiTarget: 'https://public.dify.ai',
enterpriseApiTarget: 'https://enterprise.dify.ai',
routes: [
{
paths: '/console/api',
target: 'https://cloud.example.com',
cookieRewrite: {
hostPrefixCookies: ['access_token'],
},
},
],
fetchImpl,
})
@@ -77,7 +62,7 @@ describe('dev proxy server', () => {
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',
'Cookie': 'access_token=abc; theme=dark',
'Accept-Encoding': 'zstd, br, gzip',
},
})
@@ -85,7 +70,7 @@ describe('dev proxy server', () => {
// Assert
expect(fetchImpl).toHaveBeenCalledTimes(1)
expect(fetchImpl).toHaveBeenCalledWith(
new URL('https://cloud.dify.ai/console/api/apps?page=1'),
new URL('https://cloud.example.com/console/api/apps?page=1'),
expect.objectContaining({
method: 'GET',
headers: expect.any(Headers),
@@ -96,8 +81,8 @@ describe('dev proxy server', () => {
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('cookie')).toBe('__Host-access_token=abc; theme=dark')
expect(requestHeaders.get('origin')).toBe('https://cloud.example.com')
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')
@@ -109,14 +94,49 @@ describe('dev proxy server', () => {
])
})
// Scenario: a local HTTP Dify API expects the non-prefixed local cookie name.
// Scenario: generic proxy routes should not know Dify cookie names by default.
it('should not rewrite cookie names when cookie rewriting is not configured', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
const app = createDevProxyApp({
routes: [
{
paths: '/api',
target: 'https://api.example.com',
},
],
fetchImpl,
})
// Act
await app.request('http://127.0.0.1:5001/api/messages', {
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: local HTTP upstreams expect local cookie names even when cookie rewriting is configured.
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',
routes: [
{
paths: '/console/api',
target: 'http://127.0.0.1:5001',
cookieRewrite: {
hostPrefixCookies: ['access_token', 'refresh_token'],
},
},
],
fetchImpl,
})
@@ -135,47 +155,59 @@ describe('dev proxy server', () => {
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 () => {
// Scenario: custom route paths should support independent upstream targets.
it('should proxy custom route paths to their configured targets', 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',
routes: [
{
paths: '/api',
target: 'https://api.example.com',
},
{
paths: '/files',
target: 'https://files.example.com/assets',
},
],
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)
await app.request('http://127.0.0.1:5001/api/messages')
await app.request('http://127.0.0.1:5001/files/logo.png?size=small')
// 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',
'https://api.example.com/api/messages',
'https://files.example.com/assets/files/logo.png?size=small',
])
})
// Scenario: routes are matched in config order so callers can put specific routes first.
it('should prefer earlier route entries', async () => {
// Arrange
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(new Response('ok'))
const app = createDevProxyApp({
routes: [
{
paths: '/api/enterprise',
target: 'https://enterprise.example.com',
},
{
paths: '/api',
target: 'https://api.example.com',
},
],
fetchImpl,
})
// Act
await app.request('http://127.0.0.1:5001/api/enterprise/sso/login')
// Assert
expect(fetchImpl.mock.calls.map(([url]) => url.toString())).toEqual([
'https://enterprise.example.com/api/enterprise/sso/login',
])
})
@@ -183,9 +215,12 @@ describe('dev proxy server', () => {
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',
routes: [
{
paths: '/api',
target: 'https://api.example.com',
},
],
fetchImpl: vi.fn<typeof fetch>(),
})

View File

@@ -1,25 +1,9 @@
import type { Context, Hono } from 'hono'
import type { CookieRewriteOptions, CreateDevProxyAppOptions, DevProxyCorsAllowedOrigins, DevProxyRoute } from './types'
import { Hono as HonoApp } from 'hono'
import { DEFAULT_PROXY_TARGET, rewriteCookieHeaderForUpstream, rewriteSetCookieHeadersForLocal } from './cookies'
import { 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 LOCAL_DEV_HOSTS = new Set(['localhost', '127.0.0.1', '[::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'
@@ -28,31 +12,14 @@ const RESPONSE_HEADERS_TO_DROP = [
'content-encoding',
'content-length',
'keep-alive',
'set-cookie',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailer',
'transfer-encoding',
'upgrade',
] 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) {
@@ -66,7 +33,7 @@ const appendHeaderValue = (headers: Headers, name: string, value: string) => {
headers.set(name, `${currentValue}, ${value}`)
}
export const isAllowedDevOrigin = (origin?: string | null) => {
export const isAllowedLocalDevOrigin = (origin?: string | null) => {
if (!origin)
return false
@@ -79,8 +46,25 @@ export const isAllowedDevOrigin = (origin?: string | null) => {
}
}
const applyCorsHeaders = (headers: Headers, origin?: string | null) => {
if (!isAllowedDevOrigin(origin))
export const isAllowedDevOrigin = (
origin?: string | null,
allowedOrigins: DevProxyCorsAllowedOrigins = 'local',
) => {
if (!origin)
return false
if (allowedOrigins === 'local')
return isAllowedLocalDevOrigin(origin)
return allowedOrigins.includes(origin)
}
const applyCorsHeaders = (
headers: Headers,
origin: string | undefined | null,
allowedOrigins: DevProxyCorsAllowedOrigins = 'local',
) => {
if (!isAllowedDevOrigin(origin, allowedOrigins))
return
headers.set('Access-Control-Allow-Origin', origin!)
@@ -103,7 +87,11 @@ export const buildUpstreamUrl = (target: string, requestPath: string, search = '
return targetUrl
}
const createProxyRequestHeaders = (request: Request, targetUrl: URL) => {
const createProxyRequestHeaders = (
request: Request,
targetUrl: URL,
cookieRewrite: CookieRewriteOptions | false | undefined,
) => {
const headers = new Headers(request.headers)
headers.delete('host')
headers.set('accept-encoding', UPSTREAM_ACCEPT_ENCODING)
@@ -111,36 +99,60 @@ const createProxyRequestHeaders = (request: Request, targetUrl: URL) => {
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)
if (cookieRewrite) {
const rewrittenCookieHeader = rewriteCookieHeaderForUpstream(headers.get('cookie') || undefined, {
...cookieRewrite,
useHostPrefix: targetUrl.protocol === 'https:',
})
if (rewrittenCookieHeader)
headers.set('cookie', rewrittenCookieHeader)
}
return headers
}
const createUpstreamResponseHeaders = (response: Response, requestOrigin?: string | null) => {
const getSetCookieHeaders = (headers: Headers) => {
const headersWithGetSetCookie = headers as Headers & { getSetCookie?: () => string[] }
const setCookieHeaders = headersWithGetSetCookie.getSetCookie?.()
if (setCookieHeaders?.length)
return setCookieHeaders
const setCookie = headers.get('set-cookie')
return setCookie ? [setCookie] : []
}
const createUpstreamResponseHeaders = (
response: Response,
requestOrigin: string | undefined | null,
allowedOrigins: DevProxyCorsAllowedOrigins,
cookieRewrite: CookieRewriteOptions | false | undefined,
) => {
const headers = new Headers(response.headers)
RESPONSE_HEADERS_TO_DROP.forEach(header => headers.delete(header))
headers.delete('set-cookie')
const rewrittenSetCookies = rewriteSetCookieHeadersForLocal(response.headers.getSetCookie())
rewrittenSetCookies?.forEach((cookie) => {
const setCookieHeaders = getSetCookieHeaders(response.headers)
const responseSetCookieHeaders = cookieRewrite
? rewriteSetCookieHeadersForLocal(setCookieHeaders)
: setCookieHeaders
responseSetCookieHeaders.forEach((cookie) => {
headers.append('set-cookie', cookie)
})
applyCorsHeaders(headers, requestOrigin)
applyCorsHeaders(headers, requestOrigin, allowedOrigins)
return headers
}
const proxyRequest = async (
context: Context,
target: string,
route: DevProxyRoute,
fetchImpl: typeof globalThis.fetch,
allowedOrigins: DevProxyCorsAllowedOrigins,
) => {
const requestUrl = new URL(context.req.url)
const targetUrl = buildUpstreamUrl(target, requestUrl.pathname, requestUrl.search)
const requestHeaders = createProxyRequestHeaders(context.req.raw, targetUrl)
const targetUrl = buildUpstreamUrl(route.target, requestUrl.pathname, requestUrl.search)
const requestHeaders = createProxyRequestHeaders(context.req.raw, targetUrl, route.cookieRewrite)
const requestInit: RequestInit & { duplex?: 'half' } = {
method: context.req.method,
headers: requestHeaders,
@@ -153,7 +165,12 @@ const proxyRequest = async (
}
const upstreamResponse = await fetchImpl(targetUrl, requestInit)
const responseHeaders = createUpstreamResponseHeaders(upstreamResponse, context.req.header('origin'))
const responseHeaders = createUpstreamResponseHeaders(
upstreamResponse,
context.req.header('origin'),
allowedOrigins,
route.cookieRewrite,
)
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
@@ -162,48 +179,46 @@ const proxyRequest = async (
})
}
const normalizeRoutePaths = (paths: DevProxyRoute['paths']) => Array.isArray(paths) ? paths : [paths]
const registerProxyRoute = (
app: Hono,
path: ProxyRoutePath,
target: string,
route: DevProxyRoute,
path: string,
fetchImpl: typeof globalThis.fetch,
allowedOrigins: DevProxyCorsAllowedOrigins,
) => {
app.all(path, context => proxyRequest(context, target, fetchImpl))
app.all(`${path}/*`, context => proxyRequest(context, target, fetchImpl))
if (!path.startsWith('/'))
throw new Error(`Invalid dev proxy route path "${path}". Paths must start with "/".`)
app.all(path, context => proxyRequest(context, route, fetchImpl, allowedOrigins))
app.all(`${path}/*`, context => proxyRequest(context, route, fetchImpl, allowedOrigins))
}
const registerProxyRoutes = (
app: Hono,
routes: readonly ProxyRoutePath[],
target: string,
routes: readonly DevProxyRoute[],
fetchImpl: typeof globalThis.fetch,
allowedOrigins: DevProxyCorsAllowedOrigins,
) => {
routes.forEach(route => registerProxyRoute(app, route, target, fetchImpl))
routes.forEach((route) => {
normalizeRoutePaths(route.paths).forEach((path) => {
registerProxyRoute(app, route, path, fetchImpl, allowedOrigins)
})
})
}
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) => {
export const createDevProxyApp = (options: CreateDevProxyAppOptions) => {
const app = new HonoApp()
const fetchImpl = options.fetchImpl || globalThis.fetch
const logger = options.logger || console
const allowedOrigins = options.cors?.allowedOrigins || 'local'
app.onError((error, context) => {
console.error('[dev-hono-proxy]', error)
logger.error('[dev-proxy]', error)
const headers = new Headers()
applyCorsHeaders(headers, context.req.header('origin'))
applyCorsHeaders(headers, context.req.header('origin'), allowedOrigins)
return new Response('Upstream proxy request failed.', {
status: 502,
@@ -214,7 +229,7 @@ export const createDevProxyApp = (options: DevProxyAppOptions) => {
app.use('*', async (context, next) => {
if (context.req.method === 'OPTIONS') {
const headers = new Headers()
applyCorsHeaders(headers, context.req.header('origin'))
applyCorsHeaders(headers, context.req.header('origin'), allowedOrigins)
headers.set('Access-Control-Allow-Methods', ALLOW_METHODS)
headers.set(
'Access-Control-Allow-Headers',
@@ -230,13 +245,10 @@ export const createDevProxyApp = (options: DevProxyAppOptions) => {
}
await next()
applyCorsHeaders(context.res.headers, context.req.header('origin'))
applyCorsHeaders(context.res.headers, context.req.header('origin'), allowedOrigins)
})
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)
registerProxyRoutes(app, options.routes, fetchImpl, allowedOrigins)
return app
}

View File

@@ -0,0 +1,50 @@
export type DevProxyServerConfig = {
host?: string
port?: number
}
export type DevProxyCorsAllowedOrigins = 'local' | readonly string[]
export type DevProxyCorsConfig = {
allowedOrigins?: DevProxyCorsAllowedOrigins
}
export type CookieNameMatcher = string | RegExp
export type CookieRewriteOptions = {
hostPrefixCookies?: readonly CookieNameMatcher[]
}
export type DevProxyRoute = {
paths: string | readonly string[]
target: string
cookieRewrite?: CookieRewriteOptions | false
}
export type DevProxyConfig = {
server?: DevProxyServerConfig
routes: readonly DevProxyRoute[]
cors?: DevProxyCorsConfig
}
export type DevProxyCliOptions = {
config?: string
envFile?: string
host?: string
port?: string
help?: boolean
}
export type DevProxyConfigLoadOptions = {
envFile?: string | false
}
export type ResolvedDevProxyServerOptions = {
host: string
port: number
}
export type CreateDevProxyAppOptions = Pick<DevProxyConfig, 'routes' | 'cors'> & {
fetchImpl?: typeof globalThis.fetch
logger?: Pick<Console, 'error'>
}

View File

@@ -0,0 +1,17 @@
{
"extends": "@dify/tsconfig/node.json",
"compilerOptions": {
"types": [
"node",
"vitest/globals"
]
},
"include": [
"src/**/*.ts",
"vite.config.ts"
],
"exclude": [
"node_modules",
"dist"
]
}

View File

@@ -0,0 +1,27 @@
import { defineConfig } from 'vite-plus'
export default defineConfig({
pack: {
clean: true,
deps: {
neverBundle: [
'@hono/node-server',
'c12',
'hono',
],
},
entry: [
'src/index.ts',
'src/cli.ts',
],
format: ['esm'],
outDir: 'dist',
platform: 'node',
sourcemap: true,
target: 'node22',
treeshake: true,
},
test: {
environment: 'node',
},
})

206
pnpm-lock.yaml generated
View File

@@ -255,6 +255,9 @@ catalogs:
ahooks:
specifier: 3.9.7
version: 3.9.7
c12:
specifier: 1.10.0
version: 1.10.0
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -684,6 +687,37 @@ importers:
specifier: 'catalog:'
version: 0.1.20(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)
packages/dev-proxy:
dependencies:
'@hono/node-server':
specifier: 'catalog:'
version: 2.0.0(hono@4.12.15)
c12:
specifier: 'catalog:'
version: 1.10.0
hono:
specifier: 'catalog:'
version: 4.12.15
devDependencies:
'@dify/tsconfig':
specifier: workspace:*
version: link:../tsconfig
'@types/node':
specifier: 'catalog:'
version: 25.6.0
'@typescript/native-preview':
specifier: 'catalog:'
version: 7.0.0-dev.20260428.1
vite:
specifier: npm:@voidzero-dev/vite-plus-core@0.1.20
version: '@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)'
vite-plus:
specifier: 'catalog:'
version: 0.1.20(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)
vitest:
specifier: npm:@voidzero-dev/vite-plus-test@0.1.20
version: '@voidzero-dev/vite-plus-test@0.1.20(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.20(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)'
packages/dify-ui:
dependencies:
clsx:
@@ -1174,15 +1208,15 @@ importers:
'@eslint-react/eslint-plugin':
specifier: 'catalog:'
version: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)
'@hono/node-server':
specifier: 'catalog:'
version: 2.0.0(hono@4.12.15)
'@iconify-json/heroicons':
specifier: 'catalog:'
version: 1.2.3
'@iconify-json/ri':
specifier: 'catalog:'
version: 1.2.10
'@langgenius/dev-proxy':
specifier: workspace:*
version: link:../packages/dev-proxy
'@langgenius/dify-ui':
specifier: workspace:*
version: link:../packages/dify-ui
@@ -1336,9 +1370,6 @@ importers:
happy-dom:
specifier: 'catalog:'
version: 20.9.0
hono:
specifier: 'catalog:'
version: 4.12.15
knip:
specifier: 'catalog:'
version: 6.7.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)
@@ -4785,6 +4816,10 @@ packages:
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
anymatch@3.1.3:
resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==}
engines: {node: '>= 8'}
are-docs-informative@0.0.2:
resolution: {integrity: sha512-ixiS0nLNNG5jNQzgZJNoUpBKdo9yTYZMGJ+QgT2jmjR7G7+QHRCc4v6LQ3NgE7EBJq+o0ams3waJwkrlBom8Ig==}
engines: {node: '>=14'}
@@ -4847,6 +4882,10 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
birecord@0.1.1:
resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==}
@@ -4897,6 +4936,9 @@ packages:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
c12@1.10.0:
resolution: {integrity: sha512-0SsG7UDhoRWcuSvKWHaXmu5uNjDCDN3nkQLRL4Q42IlFy+ze58FcCoI3uPwINXinkz7ZinbhEgyzYFw9u9ZV8g==}
c12@3.3.4:
resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==}
peerDependencies:
@@ -4971,6 +5013,10 @@ packages:
chevrotain@11.1.2:
resolution: {integrity: sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==}
chokidar@3.6.0:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
@@ -4998,6 +5044,9 @@ packages:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
class-transformer@0.5.1:
resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==}
@@ -5097,6 +5146,10 @@ packages:
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
@@ -6018,12 +6071,13 @@ packages:
resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==}
engines: {node: '>=8'}
get-tsconfig@4.13.7:
resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==}
get-tsconfig@4.14.0:
resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
giget@1.2.5:
resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==}
hasBin: true
giget@3.2.0:
resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==}
hasBin: true
@@ -6251,6 +6305,10 @@ packages:
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
is-binary-path@2.1.0:
resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==}
engines: {node: '>=8'}
is-builtin-module@5.0.0:
resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==}
engines: {node: '>=18.20'}
@@ -6322,6 +6380,10 @@ packages:
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
engines: {node: '>=8'}
jiti@1.21.7:
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
hasBin: true
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -6945,6 +7007,9 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
@@ -6952,6 +7017,10 @@ packages:
resolution: {integrity: sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==}
engines: {node: ^20.17.0 || >=22.9.0}
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
normalize-wheel@1.0.1:
resolution: {integrity: sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA==}
@@ -6979,6 +7048,11 @@ packages:
react-router-dom:
optional: true
nypm@0.5.4:
resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==}
engines: {node: ^14.16.0 || >=16.10.0}
hasBin: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -6989,6 +7063,9 @@ packages:
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
ohash@1.1.6:
resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
@@ -7124,6 +7201,9 @@ packages:
resolution: {integrity: sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==}
engines: {node: '>=6'}
pathe@1.1.2:
resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -7138,6 +7218,9 @@ packages:
pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
perfect-debounce@1.0.0:
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
@@ -7272,6 +7355,9 @@ packages:
resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==}
engines: {node: '>=14.18.0'}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
rc9@3.0.1:
resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==}
@@ -7448,6 +7534,10 @@ packages:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
@@ -7919,6 +8009,9 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@0.3.2:
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
tinyexec@1.0.4:
resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'}
@@ -12097,6 +12190,11 @@ snapshots:
any-promise@1.3.0: {}
anymatch@3.1.3:
dependencies:
normalize-path: 3.0.0
picomatch: 2.3.2
are-docs-informative@0.0.2: {}
argparse@2.0.1: {}
@@ -12146,6 +12244,8 @@ snapshots:
baseline-browser-mapping@2.10.12: {}
binary-extensions@2.3.0: {}
birecord@0.1.1: {}
birpc@4.0.0: {}
@@ -12195,6 +12295,21 @@ snapshots:
bytes@3.1.2: {}
c12@1.10.0:
dependencies:
chokidar: 3.6.0
confbox: 0.1.8
defu: 6.1.7
dotenv: 16.6.1
giget: 1.2.5
jiti: 1.21.7
mlly: 1.8.2
ohash: 1.1.6
pathe: 1.1.2
perfect-debounce: 1.0.0
pkg-types: 1.3.1
rc9: 2.1.2
c12@3.3.4(magicast@0.5.2):
dependencies:
chokidar: 5.0.0
@@ -12299,6 +12414,18 @@ snapshots:
'@chevrotain/utils': 11.1.2
lodash-es: 4.18.0
chokidar@3.6.0:
dependencies:
anymatch: 3.1.3
braces: 3.0.3
glob-parent: 5.1.2
is-binary-path: 2.1.0
is-glob: 4.0.3
normalize-path: 3.0.0
readdirp: 3.6.0
optionalDependencies:
fsevents: 2.3.3
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
@@ -12312,6 +12439,10 @@ snapshots:
ci-info@4.4.0: {}
citty@0.1.6:
dependencies:
consola: 3.4.2
class-transformer@0.5.1: {}
class-variance-authority@0.7.1:
@@ -12407,6 +12538,8 @@ snapshots:
confbox@0.2.4: {}
consola@3.4.2: {}
convert-source-map@2.0.0: {}
copy-to-clipboard@4.0.2: {}
@@ -13513,14 +13646,20 @@ snapshots:
dependencies:
pump: 3.0.4
get-tsconfig@4.13.7:
dependencies:
resolve-pkg-maps: 1.0.0
get-tsconfig@4.14.0:
dependencies:
resolve-pkg-maps: 1.0.0
giget@1.2.5:
dependencies:
citty: 0.1.6
consola: 3.4.2
defu: 6.1.7
node-fetch-native: 1.6.7
nypm: 0.5.4
pathe: 2.0.3
tar: 7.5.11
giget@3.2.0: {}
github-from-package@0.0.0:
@@ -13820,6 +13959,10 @@ snapshots:
is-alphabetical: 2.0.1
is-decimal: 2.0.1
is-binary-path@2.1.0:
dependencies:
binary-extensions: 2.3.0
is-builtin-module@5.0.0:
dependencies:
builtin-modules: 5.0.0
@@ -13874,6 +14017,8 @@ snapshots:
html-escaper: 2.0.2
istanbul-lib-report: 3.0.1
jiti@1.21.7: {}
jiti@2.6.1: {}
jotai@2.19.1(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.14)(react@19.2.5):
@@ -14766,6 +14911,8 @@ snapshots:
node-addon-api@7.1.1:
optional: true
node-fetch-native@1.6.7: {}
node-releases@2.0.36: {}
normalize-package-data@8.0.0:
@@ -14774,6 +14921,8 @@ snapshots:
semver: 7.7.4
validate-npm-package-license: 3.0.4
normalize-path@3.0.0: {}
normalize-wheel@1.0.1: {}
nth-check@2.1.1:
@@ -14787,12 +14936,23 @@ snapshots:
optionalDependencies:
next: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
nypm@0.5.4:
dependencies:
citty: 0.1.6
consola: 3.4.2
pathe: 2.0.3
pkg-types: 1.3.1
tinyexec: 0.3.2
ufo: 1.6.3
object-assign@4.1.1: {}
object-deep-merge@2.0.0: {}
obug@2.1.1: {}
ohash@1.1.6: {}
ohash@2.0.11: {}
once@1.4.0:
@@ -15027,6 +15187,8 @@ snapshots:
path2d@0.2.2:
optional: true
pathe@1.1.2: {}
pathe@2.0.3: {}
pathval@2.0.1: {}
@@ -15038,6 +15200,8 @@ snapshots:
pend@1.2.0: {}
perfect-debounce@1.0.0: {}
perfect-debounce@2.1.0: {}
picocolors@1.1.1: {}
@@ -15177,6 +15341,11 @@ snapshots:
radash@12.1.1: {}
rc9@2.1.2:
dependencies:
defu: 6.1.7
destr: 2.0.5
rc9@3.0.1:
dependencies:
defu: 6.1.7
@@ -15380,6 +15549,10 @@ snapshots:
util-deprecate: 1.0.2
optional: true
readdirp@3.6.0:
dependencies:
picomatch: 2.3.2
readdirp@5.0.0: {}
recast@0.23.11:
@@ -15968,6 +16141,8 @@ snapshots:
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
tinyexec@1.0.4: {}
tinyglobby@0.2.16:
@@ -16053,7 +16228,7 @@ snapshots:
tsx@4.21.0:
dependencies:
esbuild: 0.27.2
get-tsconfig: 4.13.7
get-tsconfig: 4.14.0
optionalDependencies:
fsevents: 2.3.3
@@ -16609,15 +16784,18 @@ time:
'@typescript/native-preview@7.0.0-dev.20260428.1': '2026-04-28T08:09:51.266Z'
'@voidzero-dev/vite-plus-core@0.1.20': '2026-04-29T03:08:39.629Z'
'@voidzero-dev/vite-plus-test@0.1.20': '2026-04-29T03:08:45.501Z'
c12@1.10.0: '2024-03-06T13:11:04.381Z'
concurrently@9.2.1: '2025-08-25T09:50:49.138Z'
copy-to-clipboard@4.0.2: '2026-04-24T22:15:18.933Z'
eslint-markdown@0.7.0: '2026-04-25T11:31:20.226Z'
eslint-plugin-better-tailwindcss@4.5.0: '2026-04-28T06:24:47.281Z'
eslint@10.2.1: '2026-04-17T20:17:44.852Z'
hono@4.12.15: '2026-04-24T06:51:10.290Z'
i18next@26.0.8: '2026-04-24T19:20:14.685Z'
js-yaml@4.1.1: '2025-11-12T15:18:03.524Z'
lexical@0.44.0: '2026-04-27T14:47:00.970Z'
tldts@7.0.29: '2026-04-28T12:21:32.710Z'
tsx@4.21.0: '2025-11-30T15:56:09.488Z'
typescript@6.0.3: '2026-04-16T23:38:27.905Z'
uuid@14.0.0: '2026-04-19T15:15:42.302Z'
vinext@0.0.45: '2026-04-28T11:43:03.463Z'

View File

@@ -138,6 +138,7 @@ catalog:
abcjs: 6.6.3
agentation: 3.0.2
ahooks: 3.9.7
c12: 1.10.0
class-variance-authority: 0.7.1
client-only: 0.0.1
clsx: 2.1.1

View File

@@ -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

View File

@@ -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.

50
web/dev-proxy.config.ts Normal file
View File

@@ -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

View File

@@ -8,6 +8,7 @@ const config: KnipConfig = {
'scripts/**/*.{js,ts,mjs}',
'bin/**/*.{js,ts,mjs}',
'tsslint.config.ts',
'dev-proxy.config.ts',
],
ignore: [
'public/**',

View File

@@ -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:",

View File

@@ -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')
})
})

View File

@@ -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,
}
}

View File

@@ -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}`)