mirror of
https://github.com/langgenius/dify.git
synced 2026-05-08 00:02:34 -04:00
feat(dev-proxy): init package (#35852)
This commit is contained in:
196
packages/dev-proxy/README.md
Normal file
196
packages/dev-proxy/README.md
Normal 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.
|
||||
3
packages/dev-proxy/bin/dev-proxy.js
Executable file
3
packages/dev-proxy/bin/dev-proxy.js
Executable file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import '../dist/cli.mjs'
|
||||
43
packages/dev-proxy/package.json
Normal file
43
packages/dev-proxy/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
158
packages/dev-proxy/src/cli.spec.ts
Normal file
158
packages/dev-proxy/src/cli.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
56
packages/dev-proxy/src/cli.ts
Normal file
56
packages/dev-proxy/src/cli.ts
Normal 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)
|
||||
}
|
||||
145
packages/dev-proxy/src/config.spec.ts
Normal file
145
packages/dev-proxy/src/config.spec.ts
Normal 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',
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
129
packages/dev-proxy/src/config.ts
Normal file
129
packages/dev-proxy/src/config.ts
Normal 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
|
||||
44
packages/dev-proxy/src/cookies.spec.ts
Normal file
44
packages/dev-proxy/src/cookies.spec.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
22
packages/dev-proxy/src/index.ts
Normal file
22
packages/dev-proxy/src/index.ts
Normal 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'
|
||||
@@ -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>(),
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
50
packages/dev-proxy/src/types.ts
Normal file
50
packages/dev-proxy/src/types.ts
Normal 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'>
|
||||
}
|
||||
17
packages/dev-proxy/tsconfig.json
Normal file
17
packages/dev-proxy/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
27
packages/dev-proxy/vite.config.ts
Normal file
27
packages/dev-proxy/vite.config.ts
Normal 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
206
pnpm-lock.yaml
generated
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
50
web/dev-proxy.config.ts
Normal 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
|
||||
@@ -8,6 +8,7 @@ const config: KnipConfig = {
|
||||
'scripts/**/*.{js,ts,mjs}',
|
||||
'bin/**/*.{js,ts,mjs}',
|
||||
'tsslint.config.ts',
|
||||
'dev-proxy.config.ts',
|
||||
],
|
||||
ignore: [
|
||||
'public/**',
|
||||
|
||||
@@ -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,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