feat(dev-proxy): reload env file changes (#36384)

This commit is contained in:
yyh
2026-05-19 16:24:47 +08:00
committed by GitHub
parent 5276eb689b
commit a328bbbced
9 changed files with 274 additions and 15 deletions

View File

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

View File

@@ -30,6 +30,7 @@
"dependencies": {
"@hono/node-server": "catalog:",
"c12": "catalog:",
"chokidar": "catalog:",
"hono": "catalog:"
},
"devDependencies": {

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,6 +39,7 @@ export type DevProxyCliOptions = {
envFile?: string
host?: string
port?: string
watch?: boolean
help?: boolean
}

7
pnpm-lock.yaml generated
View File

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

View File

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