diff --git a/packages/dev-proxy/README.md b/packages/dev-proxy/README.md index fff99a9123..83c346f75c 100644 --- a/packages/dev-proxy/README.md +++ b/packages/dev-proxy/README.md @@ -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: diff --git a/packages/dev-proxy/package.json b/packages/dev-proxy/package.json index d5524290eb..d6b28e113c 100644 --- a/packages/dev-proxy/package.json +++ b/packages/dev-proxy/package.json @@ -30,6 +30,7 @@ "dependencies": { "@hono/node-server": "catalog:", "c12": "catalog:", + "chokidar": "catalog:", "hono": "catalog:" }, "devDependencies": { diff --git a/packages/dev-proxy/src/cli.spec.ts b/packages/dev-proxy/src/cli.spec.ts index e8a87a0588..87a868b410 100644 --- a/packages/dev-proxy/src/cli.spec.ts +++ b/packages/dev-proxy/src/cli.spec.ts @@ -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 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((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((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() + }) }) diff --git a/packages/dev-proxy/src/cli.ts b/packages/dev-proxy/src/cli.ts index 05234cb359..759045d641 100644 --- a/packages/dev-proxy/src/cli.ts +++ b/packages/dev-proxy/src/cli.ts @@ -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 Load environment variables before evaluating the config file. --host Override the configured host. --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((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, 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 { diff --git a/packages/dev-proxy/src/config.spec.ts b/packages/dev-proxy/src/config.spec.ts index 6f681bcbae..46293f3e07 100644 --- a/packages/dev-proxy/src/config.spec.ts +++ b/packages/dev-proxy/src/config.spec.ts @@ -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, }) }) diff --git a/packages/dev-proxy/src/config.ts b/packages/dev-proxy/src/config.ts index b23cb0a152..a07d969ea2 100644 --- a/packages/dev-proxy/src/config.ts +++ b/packages/dev-proxy/src/config.ts @@ -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 => { +): LoadConfigOptions => { 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 => { + 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, 'onUpdate'> = {}, +) => { + const watcher = await watchConfig({ + ...createC12ConfigOptions(configPath, cwd, options), + onUpdate: options.onUpdate, + }) + + assertDevProxyConfig(watcher.config) + return watcher +} + export const defineDevProxyConfig = (config: DevProxyConfig) => config diff --git a/packages/dev-proxy/src/types.ts b/packages/dev-proxy/src/types.ts index 5257ffaa50..3bf2a3c7f3 100644 --- a/packages/dev-proxy/src/types.ts +++ b/packages/dev-proxy/src/types.ts @@ -39,6 +39,7 @@ export type DevProxyCliOptions = { envFile?: string host?: string port?: string + watch?: boolean help?: boolean } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fdbca121b..42714bf6a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8b06c92a52..fb10935f0b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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