mirror of
https://github.com/langgenius/dify.git
synced 2026-05-25 11:27:19 +08:00
feat(dev-proxy): reload env file changes (#36384)
This commit is contained in:
@ -13,7 +13,7 @@ Add a script in your frontend project:
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env"
|
||||
"dev:proxy": "dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local"
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -36,10 +36,14 @@ Supported options:
|
||||
- `--env-file`: load environment variables before evaluating the config file.
|
||||
- `--host`: override `server.host` from config.
|
||||
- `--port`: override `server.port` from config.
|
||||
- `--watch`: reload config and env file changes. Enabled by default.
|
||||
- `--no-watch`: disable config and env file reloads.
|
||||
- `--help`, `-h`: print help.
|
||||
|
||||
`--target` is not supported. Put targets in the config file so routes and upstreams stay explicit.
|
||||
|
||||
The CLI watches the config file and the explicit `--env-file` by default. Route, CORS, target, and cookie rewrite changes are applied in the running process. If the resolved host or port changes, the proxy closes the old server and starts a new one.
|
||||
|
||||
## Config Shape
|
||||
|
||||
```ts
|
||||
@ -108,9 +112,11 @@ DEV_PROXY_PORT=5001
|
||||
Command:
|
||||
|
||||
```bash
|
||||
dev-proxy --config ./dev-proxy.config.ts --env-file ./.env
|
||||
dev-proxy --config ./dev-proxy.config.ts --env-file ./.env.local
|
||||
```
|
||||
|
||||
Edits to `./.env.local` reload the proxy automatically.
|
||||
|
||||
## 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:
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"dependencies": {
|
||||
"@hono/node-server": "catalog:",
|
||||
"c12": "catalog:",
|
||||
"chokidar": "catalog:",
|
||||
"hono": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -2,10 +2,12 @@
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import type { Server } from 'node:http'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { once } from 'node:events'
|
||||
import fs from 'node:fs/promises'
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
@ -16,6 +18,7 @@ const tempDirs: string[] = []
|
||||
type DevProxyCliProcess = ChildProcessByStdio<null, Readable, Readable>
|
||||
|
||||
const childProcesses: DevProxyCliProcess[] = []
|
||||
const httpServers: Server[] = []
|
||||
const binPath = fileURLToPath(new URL('../bin/dev-proxy.js', import.meta.url))
|
||||
|
||||
const createTempDir = async () => {
|
||||
@ -86,6 +89,23 @@ const waitForOutput = (
|
||||
onData()
|
||||
})
|
||||
|
||||
const fetchTextWithRetry = async (url: string) => {
|
||||
let lastError: unknown
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
return response.text()
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
const spawnCli = (args: readonly string[], cwd: string) => {
|
||||
const child = spawn(process.execPath, [binPath, ...args], {
|
||||
cwd,
|
||||
@ -107,9 +127,45 @@ const stopChildProcess = async (child: DevProxyCliProcess) => {
|
||||
await once(child, 'exit')
|
||||
}
|
||||
|
||||
const closeHttpServer = async (server: Server) => {
|
||||
if (!server.listening)
|
||||
return
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error)
|
||||
reject(error)
|
||||
else
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const startTextServer = async (body: string) => {
|
||||
const server = http.createServer((_, response) => {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' })
|
||||
response.end(body)
|
||||
})
|
||||
|
||||
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 start test server.')
|
||||
|
||||
httpServers.push(server)
|
||||
return {
|
||||
port: address.port,
|
||||
}
|
||||
}
|
||||
|
||||
describe('dev proxy CLI', () => {
|
||||
afterEach(async () => {
|
||||
await Promise.all(childProcesses.splice(0).map(stopChildProcess))
|
||||
await Promise.all(httpServers.splice(0).map(closeHttpServer))
|
||||
await Promise.all(tempDirs.splice(0).map(tempDir => fs.rm(tempDir, {
|
||||
force: true,
|
||||
recursive: true,
|
||||
@ -155,4 +211,49 @@ describe('dev proxy CLI', () => {
|
||||
expect(child.signalCode).toBeNull()
|
||||
expect(response.status).toBe(404)
|
||||
})
|
||||
|
||||
// Scenario: editing the configured env file should reload route targets without restarting the CLI process.
|
||||
it('should reload proxy config when the env file changes', async () => {
|
||||
// Arrange
|
||||
const tempDir = await createTempDir()
|
||||
const port = await getFreePort()
|
||||
const firstTarget = await startTextServer('first target')
|
||||
const secondTarget = await startTextServer('second target')
|
||||
|
||||
await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${firstTarget.port}\n`)
|
||||
await fs.writeFile(path.join(tempDir, 'dev-proxy.config.ts'), `
|
||||
export default {
|
||||
routes: [{ paths: '/api', target: process.env.DEV_PROXY_TEST_TARGET }],
|
||||
}
|
||||
`)
|
||||
|
||||
let output = ''
|
||||
const child = spawnCli([
|
||||
'--config',
|
||||
'./dev-proxy.config.ts',
|
||||
'--env-file',
|
||||
'./.env.proxy',
|
||||
'--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())
|
||||
const proxyUrl = `http://127.0.0.1:${port}/api/ping`
|
||||
|
||||
// Act
|
||||
await waitForOutput(child, () => output, `[dev-proxy] listening on http://127.0.0.1:${port}`)
|
||||
const firstResponse = await fetchTextWithRetry(proxyUrl)
|
||||
|
||||
await fs.writeFile(path.join(tempDir, '.env.proxy'), `DEV_PROXY_TEST_TARGET=http://127.0.0.1:${secondTarget.port}\n`)
|
||||
await waitForOutput(child, () => output, '[dev-proxy] reloaded env file changes')
|
||||
const secondResponse = await fetchTextWithRetry(proxyUrl)
|
||||
|
||||
// Assert
|
||||
expect(firstResponse).toBe('first target')
|
||||
expect(secondResponse).toBe('second target')
|
||||
expect(child.exitCode).toBeNull()
|
||||
expect(child.signalCode).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import type { ServerType } from '@hono/node-server'
|
||||
import type { DevProxyCliOptions, DevProxyConfig } from './types'
|
||||
import process from 'node:process'
|
||||
import { serve } from '@hono/node-server'
|
||||
import { loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions } from './config'
|
||||
import { watch } from 'chokidar'
|
||||
import { assertDevProxyConfig, loadDevProxyConfig, parseDevProxyCliArgs, resolveDevProxyServerOptions, watchDevProxyConfig } from './config'
|
||||
import { createDevProxyApp } from './server'
|
||||
|
||||
function printUsage() {
|
||||
@ -12,6 +15,8 @@ Options:
|
||||
--env-file <path> Load environment variables before evaluating the config file.
|
||||
--host <host> Override the configured host.
|
||||
--port <port> Override the configured port.
|
||||
--watch Reload config and env file changes. Enabled by default.
|
||||
--no-watch Disable config and env file reloads.
|
||||
--help, -h Show this help message.`)
|
||||
}
|
||||
|
||||
@ -22,6 +27,78 @@ async function flushStandardStreams() {
|
||||
])
|
||||
}
|
||||
|
||||
const closeServer = (server: ServerType) => new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error)
|
||||
reject(error)
|
||||
else
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
|
||||
const startDevProxyServer = (config: DevProxyConfig, cliOptions: DevProxyCliOptions) => {
|
||||
let app = createDevProxyApp(config)
|
||||
const { host, port } = resolveDevProxyServerOptions(config.server, cliOptions)
|
||||
const server = serve({
|
||||
fetch: (request, env) => app.fetch(request, env),
|
||||
hostname: host,
|
||||
port,
|
||||
})
|
||||
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
server,
|
||||
updateConfig(nextConfig: DevProxyConfig) {
|
||||
app = createDevProxyApp(nextConfig)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const createDevProxyRuntime = (initialConfig: DevProxyConfig, cliOptions: DevProxyCliOptions) => {
|
||||
let runtime = startDevProxyServer(initialConfig, cliOptions)
|
||||
let reloadTask = Promise.resolve()
|
||||
|
||||
console.log(`[dev-proxy] listening on http://${runtime.host}:${runtime.port}`)
|
||||
|
||||
const reload = async (nextConfig: unknown, reason: string) => {
|
||||
assertDevProxyConfig(nextConfig)
|
||||
const nextServerOptions = resolveDevProxyServerOptions(nextConfig.server, cliOptions)
|
||||
|
||||
if (runtime.host === nextServerOptions.host && runtime.port === nextServerOptions.port) {
|
||||
runtime.updateConfig(nextConfig)
|
||||
console.log(`[dev-proxy] reloaded ${reason}`)
|
||||
return
|
||||
}
|
||||
|
||||
await closeServer(runtime.server)
|
||||
runtime = startDevProxyServer(nextConfig, cliOptions)
|
||||
console.log(`[dev-proxy] restarted on http://${runtime.host}:${runtime.port} after ${reason}`)
|
||||
}
|
||||
|
||||
const enqueueReload = (loadConfig: () => Promise<unknown> | unknown, reason: string) => {
|
||||
reloadTask = reloadTask.then(async () => {
|
||||
try {
|
||||
await reload(await loadConfig(), reason)
|
||||
}
|
||||
catch (error) {
|
||||
console.error(`[dev-proxy] failed to reload ${reason}`)
|
||||
console.error(error instanceof Error ? error.message : error)
|
||||
}
|
||||
})
|
||||
|
||||
return reloadTask
|
||||
}
|
||||
|
||||
return {
|
||||
enqueueReload,
|
||||
close: async () => {
|
||||
await reloadTask
|
||||
await closeServer(runtime.server)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cliOptions = parseDevProxyCliArgs(process.argv.slice(2))
|
||||
|
||||
@ -33,16 +110,44 @@ async function main() {
|
||||
const config = await loadDevProxyConfig(cliOptions.config, process.cwd(), {
|
||||
envFile: cliOptions.envFile,
|
||||
})
|
||||
const { host, port } = resolveDevProxyServerOptions(config.server, cliOptions)
|
||||
const app = createDevProxyApp(config)
|
||||
const runtime = createDevProxyRuntime(config, cliOptions)
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
hostname: host,
|
||||
port,
|
||||
if (cliOptions.watch === false)
|
||||
return
|
||||
|
||||
const configWatcher = await watchDevProxyConfig(cliOptions.config, process.cwd(), {
|
||||
envFile: cliOptions.envFile,
|
||||
onUpdate: ({ newConfig }) => runtime.enqueueReload(() => newConfig.config, 'config changes'),
|
||||
})
|
||||
|
||||
console.log(`[dev-proxy] listening on http://${host}:${port}`)
|
||||
const envWatcher = cliOptions.envFile
|
||||
? watch(cliOptions.envFile, {
|
||||
cwd: process.cwd(),
|
||||
ignoreInitial: true,
|
||||
})
|
||||
: undefined
|
||||
|
||||
envWatcher?.on('all', () => {
|
||||
void runtime.enqueueReload(
|
||||
() => loadDevProxyConfig(cliOptions.config, process.cwd(), {
|
||||
envFile: cliOptions.envFile,
|
||||
}),
|
||||
'env file changes',
|
||||
)
|
||||
})
|
||||
|
||||
const cleanup = async () => {
|
||||
await envWatcher?.close()
|
||||
await configWatcher.unwatch()
|
||||
await runtime.close()
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => {
|
||||
void cleanup().finally(() => process.exit(0))
|
||||
})
|
||||
process.once('SIGTERM', () => {
|
||||
void cleanup().finally(() => process.exit(0))
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@ -37,6 +37,7 @@ describe('dev proxy config', () => {
|
||||
'0.0.0.0',
|
||||
'--port',
|
||||
'8083',
|
||||
'--no-watch',
|
||||
])
|
||||
|
||||
// Assert
|
||||
@ -45,6 +46,7 @@ describe('dev proxy config', () => {
|
||||
envFile: './.env.proxy',
|
||||
host: '0.0.0.0',
|
||||
port: '8083',
|
||||
watch: false,
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { DotenvOptions } from 'c12'
|
||||
import type { DotenvOptions, LoadConfigOptions, WatchConfigOptions } from 'c12'
|
||||
import type { DevProxyCliOptions, DevProxyConfig, DevProxyConfigLoadOptions, DevProxyServerConfig, ResolvedDevProxyServerOptions } from './types'
|
||||
import path from 'node:path'
|
||||
import { loadConfig } from 'c12'
|
||||
import { loadConfig, watchConfig } from 'c12'
|
||||
|
||||
const DEFAULT_CONFIG_FILE = 'dev-proxy.config.ts'
|
||||
const DEFAULT_PROXY_HOST = '127.0.0.1'
|
||||
@ -40,6 +40,16 @@ export const parseDevProxyCliArgs = (argv: readonly string[]): DevProxyCliOption
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--watch') {
|
||||
options.watch = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (arg === '--no-watch') {
|
||||
options.watch = false
|
||||
continue
|
||||
}
|
||||
|
||||
const [rawName, inlineValue] = arg.split('=', 2)
|
||||
const name = rawName ?? ''
|
||||
|
||||
@ -105,14 +115,15 @@ const resolveDotenvOptions = (
|
||||
}
|
||||
}
|
||||
|
||||
export const loadDevProxyConfig = async (
|
||||
const createC12ConfigOptions = (
|
||||
configPath = DEFAULT_CONFIG_FILE,
|
||||
cwd = process.cwd(),
|
||||
options: DevProxyConfigLoadOptions = {},
|
||||
): Promise<DevProxyConfig> => {
|
||||
): LoadConfigOptions<DevProxyConfig> => {
|
||||
const resolvedConfigPath = path.resolve(cwd, configPath)
|
||||
const parsedPath = path.parse(resolvedConfigPath)
|
||||
const { config: loadedConfig } = await loadConfig({
|
||||
|
||||
return {
|
||||
configFile: parsedPath.name,
|
||||
cwd: parsedPath.dir,
|
||||
dotenv: resolveDotenvOptions(options.envFile, cwd),
|
||||
@ -120,10 +131,34 @@ export const loadDevProxyConfig = async (
|
||||
globalRc: false,
|
||||
packageJson: false,
|
||||
rcFile: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const loadDevProxyConfig = async (
|
||||
configPath = DEFAULT_CONFIG_FILE,
|
||||
cwd = process.cwd(),
|
||||
options: DevProxyConfigLoadOptions = {},
|
||||
): Promise<DevProxyConfig> => {
|
||||
const { config: loadedConfig } = await loadConfig({
|
||||
...createC12ConfigOptions(configPath, cwd, options),
|
||||
})
|
||||
|
||||
assertDevProxyConfig(loadedConfig)
|
||||
return loadedConfig
|
||||
}
|
||||
|
||||
export const watchDevProxyConfig = async (
|
||||
configPath = DEFAULT_CONFIG_FILE,
|
||||
cwd = process.cwd(),
|
||||
options: DevProxyConfigLoadOptions & Pick<WatchConfigOptions<DevProxyConfig>, 'onUpdate'> = {},
|
||||
) => {
|
||||
const watcher = await watchConfig<DevProxyConfig>({
|
||||
...createC12ConfigOptions(configPath, cwd, options),
|
||||
onUpdate: options.onUpdate,
|
||||
})
|
||||
|
||||
assertDevProxyConfig(watcher.config)
|
||||
return watcher
|
||||
}
|
||||
|
||||
export const defineDevProxyConfig = (config: DevProxyConfig) => config
|
||||
|
||||
@ -39,6 +39,7 @@ export type DevProxyCliOptions = {
|
||||
envFile?: string
|
||||
host?: string
|
||||
port?: string
|
||||
watch?: boolean
|
||||
help?: boolean
|
||||
}
|
||||
|
||||
|
||||
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
@ -255,6 +255,9 @@ catalogs:
|
||||
c12:
|
||||
specifier: 4.0.0-beta.5
|
||||
version: 4.0.0-beta.5
|
||||
chokidar:
|
||||
specifier: 5.0.0
|
||||
version: 5.0.0
|
||||
class-variance-authority:
|
||||
specifier: 0.7.1
|
||||
version: 0.7.1
|
||||
@ -699,6 +702,9 @@ importers:
|
||||
c12:
|
||||
specifier: 'catalog:'
|
||||
version: 4.0.0-beta.5(chokidar@5.0.0)(dotenv@17.4.2)(giget@3.2.0)(jiti@2.7.0)(magicast@0.5.2)
|
||||
chokidar:
|
||||
specifier: 'catalog:'
|
||||
version: 5.0.0
|
||||
hono:
|
||||
specifier: 'catalog:'
|
||||
version: 4.12.18
|
||||
@ -16265,6 +16271,7 @@ time:
|
||||
agentation@3.0.2: '2026-03-25T16:24:19.682Z'
|
||||
ahooks@3.9.7: '2026-03-23T15:49:13.605Z'
|
||||
c12@4.0.0-beta.5: '2026-05-06T17:28:34.367Z'
|
||||
chokidar@5.0.0: '2025-11-25T23:28:06.854Z'
|
||||
class-variance-authority@0.7.1: '2024-11-26T08:20:34.604Z'
|
||||
client-only@0.0.1: '2022-09-03T01:07:11.981Z'
|
||||
clsx@2.1.1: '2024-04-23T05:26:04.645Z'
|
||||
|
||||
@ -142,6 +142,7 @@ catalog:
|
||||
agentation: 3.0.2
|
||||
ahooks: 3.9.7
|
||||
c12: 4.0.0-beta.5
|
||||
chokidar: 5.0.0
|
||||
class-variance-authority: 0.7.1
|
||||
client-only: 0.0.1
|
||||
clsx: 2.1.1
|
||||
|
||||
Reference in New Issue
Block a user