From e5bdc40dce91c29ad1826c5da1077e7bf0136028 Mon Sep 17 00:00:00 2001 From: Stephen Zhou Date: Thu, 7 May 2026 11:32:14 +0800 Subject: [PATCH] feat(dev-proxy): init package (#35852) --- packages/dev-proxy/README.md | 196 +++++++++++++++++ packages/dev-proxy/bin/dev-proxy.js | 3 + packages/dev-proxy/package.json | 43 ++++ packages/dev-proxy/src/cli.spec.ts | 158 ++++++++++++++ packages/dev-proxy/src/cli.ts | 56 +++++ packages/dev-proxy/src/config.spec.ts | 145 ++++++++++++ packages/dev-proxy/src/config.ts | 129 +++++++++++ packages/dev-proxy/src/cookies.spec.ts | 44 ++++ .../dev-proxy/src}/cookies.ts | 53 ++--- packages/dev-proxy/src/index.ts | 22 ++ .../dev-proxy/src}/server.spec.ts | 193 +++++++++------- .../dev-proxy/src}/server.ts | 188 ++++++++-------- packages/dev-proxy/src/types.ts | 50 +++++ packages/dev-proxy/tsconfig.json | 17 ++ packages/dev-proxy/vite.config.ts | 27 +++ pnpm-lock.yaml | 206 ++++++++++++++++-- pnpm-workspace.yaml | 1 + web/.env.example | 17 +- web/README.md | 4 +- web/dev-proxy.config.ts | 50 +++++ web/knip.config.ts | 1 + web/package.json | 5 +- web/plugins/dev-proxy/config.spec.ts | 79 ------- web/plugins/dev-proxy/config.ts | 103 --------- web/scripts/dev-hono-proxy.ts | 22 -- 25 files changed, 1380 insertions(+), 432 deletions(-) create mode 100644 packages/dev-proxy/README.md create mode 100755 packages/dev-proxy/bin/dev-proxy.js create mode 100644 packages/dev-proxy/package.json create mode 100644 packages/dev-proxy/src/cli.spec.ts create mode 100644 packages/dev-proxy/src/cli.ts create mode 100644 packages/dev-proxy/src/config.spec.ts create mode 100644 packages/dev-proxy/src/config.ts create mode 100644 packages/dev-proxy/src/cookies.spec.ts rename {web/plugins/dev-proxy => packages/dev-proxy/src}/cookies.ts (61%) create mode 100644 packages/dev-proxy/src/index.ts rename {web/plugins/dev-proxy => packages/dev-proxy/src}/server.spec.ts (54%) rename {web/plugins/dev-proxy => packages/dev-proxy/src}/server.ts (52%) create mode 100644 packages/dev-proxy/src/types.ts create mode 100644 packages/dev-proxy/tsconfig.json create mode 100644 packages/dev-proxy/vite.config.ts create mode 100644 web/dev-proxy.config.ts delete mode 100644 web/plugins/dev-proxy/config.spec.ts delete mode 100644 web/plugins/dev-proxy/config.ts delete mode 100644 web/scripts/dev-hono-proxy.ts diff --git a/packages/dev-proxy/README.md b/packages/dev-proxy/README.md new file mode 100644 index 0000000000..6b9d7298c4 --- /dev/null +++ b/packages/dev-proxy/README.md @@ -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. diff --git a/packages/dev-proxy/bin/dev-proxy.js b/packages/dev-proxy/bin/dev-proxy.js new file mode 100755 index 0000000000..02e37f3525 --- /dev/null +++ b/packages/dev-proxy/bin/dev-proxy.js @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +import '../dist/cli.mjs' diff --git a/packages/dev-proxy/package.json b/packages/dev-proxy/package.json new file mode 100644 index 0000000000..d5524290eb --- /dev/null +++ b/packages/dev-proxy/package.json @@ -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:" + } +} diff --git a/packages/dev-proxy/src/cli.spec.ts b/packages/dev-proxy/src/cli.spec.ts new file mode 100644 index 0000000000..e8a87a0588 --- /dev/null +++ b/packages/dev-proxy/src/cli.spec.ts @@ -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 + +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((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((resolve, reject) => { + server.close((error) => { + if (error) + reject(error) + else + resolve() + }) + }) + + return port +} + +const waitForOutput = ( + child: DevProxyCliProcess, + output: () => string, + expectedOutput: string, +) => new Promise((resolve, reject) => { + let timeout: ReturnType + + 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) + }) +}) diff --git a/packages/dev-proxy/src/cli.ts b/packages/dev-proxy/src/cli.ts new file mode 100644 index 0000000000..05234cb359 --- /dev/null +++ b/packages/dev-proxy/src/cli.ts @@ -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 [options] + +Options: + --config, -c Path to a dev proxy config file. Defaults to dev-proxy.config.ts. + --env-file Load environment variables before evaluating the config file. + --host Override the configured host. + --port Override the configured port. + --help, -h Show this help message.`) +} + +async function flushStandardStreams() { + await Promise.all([ + new Promise(resolve => process.stdout.write('', () => resolve())), + new Promise(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) +} diff --git a/packages/dev-proxy/src/config.spec.ts b/packages/dev-proxy/src/config.spec.ts new file mode 100644 index 0000000000..6f681bcbae --- /dev/null +++ b/packages/dev-proxy/src/config.spec.ts @@ -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', + }, + ]) + }) +}) diff --git a/packages/dev-proxy/src/config.ts b/packages/dev-proxy/src/config.ts new file mode 100644 index 0000000000..b23cb0a152 --- /dev/null +++ b/packages/dev-proxy/src/config.ts @@ -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 => + 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 => { + 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 diff --git a/packages/dev-proxy/src/cookies.spec.ts b/packages/dev-proxy/src/cookies.spec.ts new file mode 100644 index 0000000000..4a1b614eeb --- /dev/null +++ b/packages/dev-proxy/src/cookies.spec.ts @@ -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', + ]) + }) +}) diff --git a/web/plugins/dev-proxy/cookies.ts b/packages/dev-proxy/src/cookies.ts similarity index 61% rename from web/plugins/dev-proxy/cookies.ts rename to packages/dev-proxy/src/cookies.ts index ad087d1549..61fdb6abd4 100644 --- a/web/plugins/dev-proxy/cookies.ts +++ b/packages/dev-proxy/src/cookies.ts @@ -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) diff --git a/packages/dev-proxy/src/index.ts b/packages/dev-proxy/src/index.ts new file mode 100644 index 0000000000..e35893b98f --- /dev/null +++ b/packages/dev-proxy/src/index.ts @@ -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' diff --git a/web/plugins/dev-proxy/server.spec.ts b/packages/dev-proxy/src/server.spec.ts similarity index 54% rename from web/plugins/dev-proxy/server.spec.ts rename to packages/dev-proxy/src/server.spec.ts index 4b3344be42..32c16a1807 100644 --- a/web/plugins/dev-proxy/server.spec.ts +++ b/packages/dev-proxy/src/server.spec.ts @@ -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().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().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().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().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().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(), }) diff --git a/web/plugins/dev-proxy/server.ts b/packages/dev-proxy/src/server.ts similarity index 52% rename from web/plugins/dev-proxy/server.ts rename to packages/dev-proxy/src/server.ts index e4867b6077..79654750da 100644 --- a/web/plugins/dev-proxy/server.ts +++ b/packages/dev-proxy/src/server.ts @@ -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> - -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 } diff --git a/packages/dev-proxy/src/types.ts b/packages/dev-proxy/src/types.ts new file mode 100644 index 0000000000..2c42b2f7fb --- /dev/null +++ b/packages/dev-proxy/src/types.ts @@ -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 & { + fetchImpl?: typeof globalThis.fetch + logger?: Pick +} diff --git a/packages/dev-proxy/tsconfig.json b/packages/dev-proxy/tsconfig.json new file mode 100644 index 0000000000..813a9bd8a3 --- /dev/null +++ b/packages/dev-proxy/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@dify/tsconfig/node.json", + "compilerOptions": { + "types": [ + "node", + "vitest/globals" + ] + }, + "include": [ + "src/**/*.ts", + "vite.config.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/packages/dev-proxy/vite.config.ts b/packages/dev-proxy/vite.config.ts new file mode 100644 index 0000000000..d060ae036e --- /dev/null +++ b/packages/dev-proxy/vite.config.ts @@ -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', + }, +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2021d87adc..4826ce8163 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b0c007ee4d..ee6ccf00df 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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 diff --git a/web/.env.example b/web/.env.example index ef3ecd8101..81fff4275d 100644 --- a/web/.env.example +++ b/web/.env.example @@ -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 diff --git a/web/README.md b/web/README.md index 206541eab6..1748ed6947 100644 --- a/web/README.md +++ b/web/README.md @@ -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 with your browser to see the result. diff --git a/web/dev-proxy.config.ts b/web/dev-proxy.config.ts new file mode 100644 index 0000000000..d9787795b3 --- /dev/null +++ b/web/dev-proxy.config.ts @@ -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 diff --git a/web/knip.config.ts b/web/knip.config.ts index d6b71b4af9..b18639e64f 100644 --- a/web/knip.config.ts +++ b/web/knip.config.ts @@ -8,6 +8,7 @@ const config: KnipConfig = { 'scripts/**/*.{js,ts,mjs}', 'bin/**/*.{js,ts,mjs}', 'tsslint.config.ts', + 'dev-proxy.config.ts', ], ignore: [ 'public/**', diff --git a/web/package.json b/web/package.json index a7174da9c3..917ace3025 100644 --- a/web/package.json +++ b/web/package.json @@ -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:", diff --git a/web/plugins/dev-proxy/config.spec.ts b/web/plugins/dev-proxy/config.spec.ts deleted file mode 100644 index 442bedcb12..0000000000 --- a/web/plugins/dev-proxy/config.spec.ts +++ /dev/null @@ -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') - }) -}) diff --git a/web/plugins/dev-proxy/config.ts b/web/plugins/dev-proxy/config.ts deleted file mode 100644 index c66aa5a2eb..0000000000 --- a/web/plugins/dev-proxy/config.ts +++ /dev/null @@ -1,103 +0,0 @@ -const DEV_PROXY_TARGETS = ['dify', 'enterprise'] as const - -type DevProxyTarget = typeof DEV_PROXY_TARGETS[number] - -type DevProxyConfigEnv = Partial> - -type DevProxyCliOptions = { - host?: string - port?: string - proxyTarget?: string -} - -type DevProxyServerOptions = { - host: string - port: number - proxyTarget: DevProxyTarget -} - -const DEFAULT_PROXY_HOST = '127.0.0.1' -const DEFAULT_PROXY_TARGET: DevProxyTarget = 'dify' -const DEFAULT_PROXY_PORT_BY_TARGET: Record = { - dify: 5001, - enterprise: 8082, -} - -const OPTION_NAME_TO_KEY = { - '--host': 'host', - '--port': 'port', - '--proxy-target': 'proxyTarget', - '--target': 'proxyTarget', -} as const - -type OptionName = keyof typeof OPTION_NAME_TO_KEY - -const isOptionName = (value: string): value is OptionName => value in OPTION_NAME_TO_KEY - -const requireOptionValue = (name: string, value?: string) => { - if (!value || isOptionName(value)) - throw new Error(`Missing value for ${name}.`) - - return value -} - -export const parseDevProxyCliArgs = (argv: string[]): DevProxyCliOptions => { - const options: DevProxyCliOptions = {} - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]! - const [rawName, inlineValue] = arg.split('=', 2) - const name = rawName ?? '' - - if (!isOptionName(name)) - continue - - const key = OPTION_NAME_TO_KEY[name] - options[key] = inlineValue ?? requireOptionValue(name, argv[index + 1]) - - if (inlineValue === undefined) - index += 1 - } - - return options -} - -export const resolveDevProxyTarget = (target?: string): DevProxyTarget => { - if (!target) - return DEFAULT_PROXY_TARGET - - const normalizedTarget = target.trim().toLowerCase() - if (DEV_PROXY_TARGETS.includes(normalizedTarget as DevProxyTarget)) - return normalizedTarget as DevProxyTarget - - throw new Error(`Unsupported proxy target "${target}". Expected "dify" or "enterprise".`) -} - -const resolvePort = (rawPort: string) => { - const port = Number(rawPort) - if (!Number.isInteger(port) || port < 1 || port > 65535) - throw new Error(`Invalid proxy port "${rawPort}". Expected an integer between 1 and 65535.`) - - return port -} - -export const resolveDevProxyServerOptions = ( - env: DevProxyConfigEnv = {}, - cliOptions: DevProxyCliOptions = {}, -): DevProxyServerOptions => { - const proxyTarget = resolveDevProxyTarget(cliOptions.proxyTarget || env.HONO_PROXY_TARGET) - const configuredPort = cliOptions.port || env.HONO_PROXY_PORT - - return { - host: cliOptions.host || env.HONO_PROXY_HOST || DEFAULT_PROXY_HOST, - port: configuredPort - ? resolvePort(configuredPort) - : DEFAULT_PROXY_PORT_BY_TARGET[proxyTarget], - proxyTarget, - } -} diff --git a/web/scripts/dev-hono-proxy.ts b/web/scripts/dev-hono-proxy.ts deleted file mode 100644 index 10d71c5ad7..0000000000 --- a/web/scripts/dev-hono-proxy.ts +++ /dev/null @@ -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}`)