diff --git a/src/web-server/routes/cliproxy-local-proxy.ts b/src/web-server/routes/cliproxy-local-proxy.ts index fa745d11..a8359186 100644 --- a/src/web-server/routes/cliproxy-local-proxy.ts +++ b/src/web-server/routes/cliproxy-local-proxy.ts @@ -19,9 +19,16 @@ export interface CliproxyLocalProxyDeps { resolveTargetPort?: () => number; } +/** Proxy request timeout in milliseconds (30 seconds) */ +const PROXY_TIMEOUT_MS = 30_000; + function resolveLocalCliproxyPort(): number { - const config = loadOrCreateUnifiedConfig(); - return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); + try { + const config = loadOrCreateUnifiedConfig(); + return validatePort(config.cliproxy_server?.local?.port ?? CLIPROXY_DEFAULT_PORT); + } catch { + return CLIPROXY_DEFAULT_PORT; + } } function isJsonContentType(contentType: string | string[] | undefined): boolean { @@ -30,22 +37,17 @@ function isJsonContentType(contentType: string | string[] | undefined): boolean } function buildProxyBody(req: Request): Buffer | undefined { - if (!isJsonContentType(req.headers['content-type']) || req.body === undefined) { - return undefined; - } - - const contentLengthHeader = req.headers['content-length']; - const contentLength = Array.isArray(contentLengthHeader) - ? contentLengthHeader[0] - : contentLengthHeader; - const hasTransferEncoding = req.headers['transfer-encoding'] !== undefined; - const parsedContentLength = - typeof contentLength === 'string' ? Number.parseInt(contentLength, 10) : NaN; - - if (!hasTransferEncoding && (!Number.isFinite(parsedContentLength) || parsedContentLength <= 0)) { + // If express.json() parsed the body (content-type is JSON and req.body is populated), + // re-serialize it since the original request stream was consumed by the middleware. + if ( + !isJsonContentType(req.headers['content-type']) || + req.body === undefined || + req.body === null + ) { return undefined; } + // express.json() sets req.body to the parsed value — re-serialize for the proxy target return Buffer.from(JSON.stringify(req.body)); } @@ -100,20 +102,27 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} path: targetPath, method: req.method, headers: buildProxyHeaders(req.headers, targetPort, bodyBuffer), + timeout: PROXY_TIMEOUT_MS, }, (proxyRes) => { res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); - proxyRes.pipe(res, { end: true }); + // Manual streaming instead of pipe() for Bun runtime compatibility + proxyRes.on('data', (chunk: Buffer) => res.write(chunk)); + proxyRes.on('end', () => res.end()); } ); + proxyReq.on('timeout', () => proxyReq.destroy()); + proxyReq.on('error', () => { if (!res.headersSent) { res.status(502).json({ error: 'CLIProxy is not reachable' }); } }); - req.on('aborted', () => proxyReq.destroy()); + // Clean up proxy connection when client disconnects. + // Only use res.on('close') — req.on('close') fires with req.destroyed=true + // in Bun after body consumption, which would prematurely kill the proxy. res.on('close', () => { if (!res.writableEnded) { proxyReq.destroy(); @@ -125,6 +134,14 @@ export function createCliproxyLocalProxyRouter(deps: CliproxyLocalProxyDeps = {} return; } + // For methods without a body (GET, HEAD, etc.) or when express.json() + // has already consumed the stream, end the request immediately. + const hasBody = req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'OPTIONS'; + if (!hasBody) { + proxyReq.end(); + return; + } + req.pipe(proxyReq, { end: true }); }); diff --git a/tests/unit/web-server/cliproxy-local-proxy.test.ts b/tests/unit/web-server/cliproxy-local-proxy.test.ts index 57f39755..d665731b 100644 --- a/tests/unit/web-server/cliproxy-local-proxy.test.ts +++ b/tests/unit/web-server/cliproxy-local-proxy.test.ts @@ -56,6 +56,8 @@ afterEach(async () => { continue; } + // Force-close keep-alive connections so server.close() doesn't hang + server.closeAllConnections(); await new Promise((resolve) => server.close(() => resolve())); } }); @@ -118,4 +120,33 @@ describe('cliproxy local proxy route', () => { path: '/v0/management/test', }); }); + + it('forwards GET requests and returns backend response', async () => { + const backend = await createBackendServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end('management panel'); + }); + const proxy = await createProxyServer({ + resolveTargetPort: () => backend.port, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/management.html`); + + expect(response.status).toBe(200); + expect(await response.text()).toBe('management panel'); + }); + + it('returns 502 when CLIProxy is not reachable', async () => { + // Use a port with nothing listening + const proxy = await createProxyServer({ + resolveTargetPort: () => 19999, + enforceAccess: () => true, + }); + + const response = await fetch(`${proxy.baseUrl}/api/cliproxy-local/`); + + expect(response.status).toBe(502); + expect(await response.json()).toEqual({ error: 'CLIProxy is not reachable' }); + }); });