From 512b38563a685b1c45664e9532f4a8209e536aaf Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Fri, 12 Jun 2026 15:24:24 -0600 Subject: [PATCH 1/3] fix: bump http-proxy-middleware to v4 Upgrade from ^3.0.5 to ^4.0.0, which replaces the http-proxy engine with httpxy (many upstream fixes, HTTP/2 support, better performance). v4 is ESM-only, so the static import in clickhouseProxy.ts is replaced with a lazy dynamic import() wrapper that caches after first call. --- packages/api/package.json | 2 +- .../api/src/routers/api/clickhouseProxy.ts | 27 +++++-- packages/app/package.json | 2 +- yarn.lock | 79 +++++-------------- 4 files changed, 42 insertions(+), 68 deletions(-) diff --git a/packages/api/package.json b/packages/api/package.json index 9874759096..7f87075b23 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -34,7 +34,7 @@ "express-session": "^1.17.3", "handlebars": "^4.7.9", "http-graceful-shutdown": "^3.1.13", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^4.0.0", "jsonwebtoken": "^9.0.0", "lodash": "^4.18.1", "minimist": "^1.2.7", diff --git a/packages/api/src/routers/api/clickhouseProxy.ts b/packages/api/src/routers/api/clickhouseProxy.ts index aecded4de3..4f974181b3 100644 --- a/packages/api/src/routers/api/clickhouseProxy.ts +++ b/packages/api/src/routers/api/clickhouseProxy.ts @@ -1,6 +1,5 @@ import { sanitizeUrl } from '@braintree/sanitize-url'; import express, { RequestHandler, Response } from 'express'; -import { createProxyMiddleware } from 'http-proxy-middleware'; import { z } from 'zod'; import { validateRequest } from 'zod-express-middleware'; @@ -164,9 +163,16 @@ const getConnection: RequestHandler = } }; -const proxyMiddleware: RequestHandler = - // prettier-ignore-next-line - createProxyMiddleware({ +// http-proxy-middleware v4 is ESM-only, so we use a dynamic import with a +// lazy-init wrapper to keep this CJS module compatible. +let _proxyMiddleware: RequestHandler | undefined; + +async function getProxyMiddleware(): Promise { + if (_proxyMiddleware) return _proxyMiddleware; + + const { createProxyMiddleware } = await import('http-proxy-middleware'); + + _proxyMiddleware = createProxyMiddleware({ target: '', // doesn't matter. it should be overridden by the router changeOrigin: true, pathFilter: (path, _req) => { @@ -231,7 +237,6 @@ const proxyMiddleware: RequestHandler = } try { - // TODO: Use fixRequestBody after this issue is resolved: https://github.com/chimurai/http-proxy-middleware/issues/1102 proxyReq.write(body); } catch (e) { console.error( @@ -275,6 +280,18 @@ const proxyMiddleware: RequestHandler = // }), }); + return _proxyMiddleware; +} + +const proxyMiddleware: RequestHandler = async (req, res, next) => { + try { + const middleware = await getProxyMiddleware(); + middleware(req, res, next); + } catch (e) { + next(e); + } +}; + router.get('/*', hasConnectionId, getConnection, proxyMiddleware); router.post('/*', hasConnectionId, getConnection, proxyMiddleware); diff --git a/packages/app/package.json b/packages/app/package.json index 86edb4b74e..13280fc95a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -63,7 +63,7 @@ "dayjs": "^1.11.19", "flat": "^5.0.2", "fuse.js": "^6.6.2", - "http-proxy-middleware": "^3.0.5", + "http-proxy-middleware": "^4.0.0", "immer": "^9.0.21", "jotai": "^2.5.1", "ky": "^0.30.0", diff --git a/yarn.lock b/yarn.lock index ecc5d7d547..8d3626e47e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4396,7 +4396,7 @@ __metadata: express-session: "npm:^1.17.3" handlebars: "npm:^4.7.9" http-graceful-shutdown: "npm:^3.1.13" - http-proxy-middleware: "npm:^3.0.5" + http-proxy-middleware: "npm:^4.0.0" jest: "npm:^30.2.0" jsonwebtoken: "npm:^9.0.0" lodash: "npm:^4.18.1" @@ -4505,7 +4505,7 @@ __metadata: eslint-plugin-storybook: "npm:10.1.4" flat: "npm:^5.0.2" fuse.js: "npm:^6.6.2" - http-proxy-middleware: "npm:^3.0.5" + http-proxy-middleware: "npm:^4.0.0" identity-obj-proxy: "npm:^3.0.0" immer: "npm:^9.0.21" jest: "npm:^30.2.0" @@ -9771,15 +9771,6 @@ __metadata: languageName: node linkType: hard -"@types/http-proxy@npm:^1.17.15": - version: 1.17.15 - resolution: "@types/http-proxy@npm:1.17.15" - dependencies: - "@types/node": "npm:*" - checksum: 10c0/e2bf2fcdf23c88141b8d2c85ed5e5418b62ef78285884a2b5a717af55f4d9062136aa475489d10292093343df58fb81975f34bebd6b9df322288fd9821cbee07 - languageName: node - linkType: hard - "@types/hyperdx__lucene@npm:@types/lucene@*": version: 2.1.7 resolution: "@types/lucene@npm:2.1.7" @@ -14071,18 +14062,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.3.6": - version: 4.3.7 - resolution: "debug@npm:4.3.7" - dependencies: - ms: "npm:^2.1.3" - peerDependenciesMeta: - supports-color: - optional: true - checksum: 10c0/1471db19c3b06d485a622d62f65947a19a23fbd0dd73f7fd3eafb697eec5360cde447fb075919987899b1a2096e85d35d4eb5a4de09a57600ac9cf7e6c8e768b - languageName: node - linkType: hard - "debug@npm:^4.4.3": version: 4.4.3 resolution: "debug@npm:4.4.3" @@ -15964,7 +15943,7 @@ __metadata: languageName: node linkType: hard -"eventemitter3@npm:^4.0.0, eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.7": +"eventemitter3@npm:^4.0.1, eventemitter3@npm:^4.0.7": version: 4.0.7 resolution: "eventemitter3@npm:4.0.7" checksum: 10c0/5f6d97cbcbac47be798e6355e3a7639a84ee1f7d9b199a07017f1d2f1e2fe236004d14fa5dfaeba661f94ea57805385e326236a6debbc7145c8877fbc0297c6b @@ -16593,16 +16572,6 @@ __metadata: languageName: node linkType: hard -"follow-redirects@npm:^1.0.0": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" - peerDependenciesMeta: - debug: - optional: true - checksum: 10c0/d301f430542520a54058d4aeeb453233c564aaccac835d29d15e050beb33f339ad67d9bddbce01739c5dc46a6716dbe3d9d0d5134b1ca203effa11a7ef092343 - languageName: node - linkType: hard - "follow-redirects@npm:^1.16.0": version: 1.16.0 resolution: "follow-redirects@npm:1.16.0" @@ -17731,28 +17700,16 @@ __metadata: languageName: node linkType: hard -"http-proxy-middleware@npm:^3.0.5": - version: 3.0.5 - resolution: "http-proxy-middleware@npm:3.0.5" +"http-proxy-middleware@npm:^4.0.0": + version: 4.0.0 + resolution: "http-proxy-middleware@npm:4.0.0" dependencies: - "@types/http-proxy": "npm:^1.17.15" - debug: "npm:^4.3.6" - http-proxy: "npm:^1.18.1" + debug: "npm:^4.4.3" + httpxy: "npm:^0.5.1" is-glob: "npm:^4.0.3" - is-plain-object: "npm:^5.0.0" + is-plain-obj: "npm:^4.1.0" micromatch: "npm:^4.0.8" - checksum: 10c0/89ff3c8fe65b22b8042a6173ae1b8f77c5171f7eecf3c8b5d6dcffe3c9d688acae7bcf498cc08d1525f566dc0781efaec4e2ddc49224b1f16f020de7987a446b - languageName: node - linkType: hard - -"http-proxy@npm:^1.18.1": - version: 1.18.1 - resolution: "http-proxy@npm:1.18.1" - dependencies: - eventemitter3: "npm:^4.0.0" - follow-redirects: "npm:^1.0.0" - requires-port: "npm:^1.0.0" - checksum: 10c0/148dfa700a03fb421e383aaaf88ac1d94521dfc34072f6c59770528c65250983c2e4ec996f2f03aa9f3fe46cd1270a593126068319311e3e8d9e610a37533e94 + checksum: 10c0/956cfec006b3619433e5c5ce24e9379bbe2e2ae885705df82c261530392c94c101205ef891845fe7d39b9d646a463c302b3b6d90125dc6e8dc42fbd7c6211c69 languageName: node linkType: hard @@ -17783,6 +17740,13 @@ __metadata: languageName: node linkType: hard +"httpxy@npm:^0.5.1": + version: 0.5.3 + resolution: "httpxy@npm:0.5.3" + checksum: 10c0/8120753638d1a50b13396d7c55536db076a04609dfb7eea19f5b461f9541acf4646fa6bfb8991f14bad62e0037b524481c5d625892b4ff94a6ab744149ed9387 + languageName: node + linkType: hard + "human-id@npm:^1.0.2": version: 1.0.2 resolution: "human-id@npm:1.0.2" @@ -18575,7 +18539,7 @@ __metadata: languageName: node linkType: hard -"is-plain-obj@npm:^4.0.0": +"is-plain-obj@npm:^4.0.0, is-plain-obj@npm:^4.1.0": version: 4.1.0 resolution: "is-plain-obj@npm:4.1.0" checksum: 10c0/32130d651d71d9564dc88ba7e6fda0e91a1010a3694648e9f4f47bb6080438140696d3e3e15c741411d712e47ac9edc1a8a9de1fe76f3487b0d90be06ac9975e @@ -24694,13 +24658,6 @@ __metadata: languageName: node linkType: hard -"requires-port@npm:^1.0.0": - version: 1.0.0 - resolution: "requires-port@npm:1.0.0" - checksum: 10c0/b2bfdd09db16c082c4326e573a82c0771daaf7b53b9ce8ad60ea46aa6e30aaf475fe9b164800b89f93b748d2c234d8abff945d2551ba47bf5698e04cd7713267 - languageName: node - linkType: hard - "reserved@npm:0.1.2": version: 0.1.2 resolution: "reserved@npm:0.1.2" From a89e77da0b7abd05c6aa6d1fc350b5102bdc52bb Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Fri, 12 Jun 2026 15:29:40 -0600 Subject: [PATCH 2/3] chore: add changeset --- .changeset/bump-http-proxy-middleware.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/bump-http-proxy-middleware.md diff --git a/.changeset/bump-http-proxy-middleware.md b/.changeset/bump-http-proxy-middleware.md new file mode 100644 index 0000000000..06a2586660 --- /dev/null +++ b/.changeset/bump-http-proxy-middleware.md @@ -0,0 +1,6 @@ +--- +"@hyperdx/api": patch +"@hyperdx/app": patch +--- + +Bump http-proxy-middleware to v4, replacing http-proxy with httpxy From c0d00d534e27c60c9e03b30f4cce5f20ddb76886 Mon Sep 17 00:00:00 2001 From: Brandon Pereira Date: Fri, 12 Jun 2026 15:31:18 -0600 Subject: [PATCH 3/3] fix: use dynamic import for http-proxy-middleware in app proxy Apply the same ESM-only dynamic import() pattern to the Next.js API catch-all route. Also use a cached promise (instead of a mutable variable) in both files to prevent concurrent-init races on cold start. --- .../api/src/routers/api/clickhouseProxy.ts | 232 +++++++++--------- packages/app/pages/api/[...all].ts | 34 ++- 2 files changed, 141 insertions(+), 125 deletions(-) diff --git a/packages/api/src/routers/api/clickhouseProxy.ts b/packages/api/src/routers/api/clickhouseProxy.ts index 4f974181b3..1965abc4d5 100644 --- a/packages/api/src/routers/api/clickhouseProxy.ts +++ b/packages/api/src/routers/api/clickhouseProxy.ts @@ -164,123 +164,129 @@ const getConnection: RequestHandler = }; // http-proxy-middleware v4 is ESM-only, so we use a dynamic import with a -// lazy-init wrapper to keep this CJS module compatible. -let _proxyMiddleware: RequestHandler | undefined; - -async function getProxyMiddleware(): Promise { - if (_proxyMiddleware) return _proxyMiddleware; - - const { createProxyMiddleware } = await import('http-proxy-middleware'); - - _proxyMiddleware = createProxyMiddleware({ - target: '', // doesn't matter. it should be overridden by the router - changeOrigin: true, - pathFilter: (path, _req) => { - return _req.method === 'GET' || _req.method === 'POST'; - }, - pathRewrite: function (path, req) { - const sanitizedPath = validateAndSanitizePath( - path.replace(/^\/clickhouse-proxy/, ''), - ); +// cached promise to keep this CJS module compatible and avoid concurrent-init races. +let _proxyPromise: Promise | undefined; + +function getProxyMiddleware(): Promise { + if (_proxyPromise) return _proxyPromise; + + _proxyPromise = import('http-proxy-middleware').then( + ({ createProxyMiddleware }) => + createProxyMiddleware({ + target: '', // doesn't matter. it should be overridden by the router + changeOrigin: true, + pathFilter: (path, _req) => { + return _req.method === 'GET' || _req.method === 'POST'; + }, + pathRewrite: function (path, req) { + const sanitizedPath = validateAndSanitizePath( + path.replace(/^\/clickhouse-proxy/, ''), + ); - const parsedUrl = new URL(sanitizedPath, 'http://localhost'); - const { searchParams, pathname } = parsedUrl; - - // Append user email as custom ClickHouse setting for query log annotation if the prefix was set - const hyperdxSettingPrefix = req._hdx_connection?.hyperdxSettingPrefix; - if (hyperdxSettingPrefix) { - const userEmail = req.user?.email; - if (userEmail) { - const userSettingKey = `${hyperdxSettingPrefix}${CUSTOM_SETTING_KEY_SEP}${CUSTOM_SETTING_KEY_USER_SUFFIX}`; - searchParams.set(userSettingKey, userEmail); - } else { - logger.debug('hyperdxSettingPrefix set, no session user found'); - } - } + const parsedUrl = new URL(sanitizedPath, 'http://localhost'); + const { searchParams, pathname } = parsedUrl; + + // Append user email as custom ClickHouse setting for query log annotation if the prefix was set + const hyperdxSettingPrefix = + req._hdx_connection?.hyperdxSettingPrefix; + if (hyperdxSettingPrefix) { + const userEmail = req.user?.email; + if (userEmail) { + const userSettingKey = `${hyperdxSettingPrefix}${CUSTOM_SETTING_KEY_SEP}${CUSTOM_SETTING_KEY_USER_SUFFIX}`; + searchParams.set(userSettingKey, userEmail); + } else { + logger.debug('hyperdxSettingPrefix set, no session user found'); + } + } - return `${pathname}?${searchParams.toString()}`; - }, - router: _req => { - if (!_req._hdx_connection?.host) { - throw new Error('[createProxyMiddleware] Connection not found'); - } - return _req._hdx_connection.host; - }, - on: { - proxyReq: (proxyReq, _req, res) => { - // set user-agent to the hyperdx version identifier - proxyReq.setHeader('user-agent', `hyperdx ${CODE_VERSION}`); - - if (_req._hdx_connection?.username) { - proxyReq.setHeader( - 'X-ClickHouse-User', - _req._hdx_connection.username, - ); - } - // Passwords can be empty - if (_req._hdx_connection?.password) { - proxyReq.setHeader('X-ClickHouse-Key', _req._hdx_connection.password); - } - - if (_req.method !== 'POST') { - console.error(`Unsupported method ${_req.method}`); - return res.sendStatus(405); - } - - let body = _req.body; - if (_req.headers['content-type'] === 'application/json') { - try { - body = JSON.stringify(body); - } catch (e) { - console.error(e); + return `${pathname}?${searchParams.toString()}`; + }, + router: _req => { + if (!_req._hdx_connection?.host) { + throw new Error('[createProxyMiddleware] Connection not found'); } - } + return _req._hdx_connection.host; + }, + on: { + proxyReq: (proxyReq, _req, res) => { + // set user-agent to the hyperdx version identifier + proxyReq.setHeader('user-agent', `hyperdx ${CODE_VERSION}`); + + if (_req._hdx_connection?.username) { + proxyReq.setHeader( + 'X-ClickHouse-User', + _req._hdx_connection.username, + ); + } + // Passwords can be empty + if (_req._hdx_connection?.password) { + proxyReq.setHeader( + 'X-ClickHouse-Key', + _req._hdx_connection.password, + ); + } + + if (_req.method !== 'POST') { + console.error(`Unsupported method ${_req.method}`); + return res.sendStatus(405); + } + + let body = _req.body; + if (_req.headers['content-type'] === 'application/json') { + try { + body = JSON.stringify(body); + } catch (e) { + console.error(e); + } + } + + try { + proxyReq.write(body); + } catch (e) { + console.error( + `clickhouseProxy error writing body, body is type ${typeof body}`, + ); + } + }, + proxyRes: (proxyRes, _req, res) => { + // since clickhouse v24, the cors headers * will be attached to the response by default + // which will cause the browser to block the response + if (_req.headers['access-control-request-method']) { + proxyRes.headers['access-control-allow-methods'] = + _req.headers['access-control-request-method']; + } + + if (_req.headers['access-control-request-headers']) { + proxyRes.headers['access-control-allow-headers'] = + _req.headers['access-control-request-headers']; + } + + if (_req.headers.origin) { + proxyRes.headers['access-control-allow-origin'] = + _req.headers.origin; + proxyRes.headers['access-control-allow-credentials'] = 'true'; + } + }, + error: (err, _req, _res) => { + console.error('Proxy error:', err); + (_res as Response).writeHead(500, { + 'Content-Type': 'application/json', + }); + _res.end( + JSON.stringify({ + success: false, + error: err.message || 'Failed to connect to ClickHouse server', + }), + ); + }, + }, + // ...(config.IS_DEV && { + // logger: console, + // }), + }), + ); - try { - proxyReq.write(body); - } catch (e) { - console.error( - `clickhouseProxy error writing body, body is type ${typeof body}`, - ); - } - }, - proxyRes: (proxyRes, _req, res) => { - // since clickhouse v24, the cors headers * will be attached to the response by default - // which will cause the browser to block the response - if (_req.headers['access-control-request-method']) { - proxyRes.headers['access-control-allow-methods'] = - _req.headers['access-control-request-method']; - } - - if (_req.headers['access-control-request-headers']) { - proxyRes.headers['access-control-allow-headers'] = - _req.headers['access-control-request-headers']; - } - - if (_req.headers.origin) { - proxyRes.headers['access-control-allow-origin'] = _req.headers.origin; - proxyRes.headers['access-control-allow-credentials'] = 'true'; - } - }, - error: (err, _req, _res) => { - console.error('Proxy error:', err); - (_res as Response).writeHead(500, { - 'Content-Type': 'application/json', - }); - _res.end( - JSON.stringify({ - success: false, - error: err.message || 'Failed to connect to ClickHouse server', - }), - ); - }, - }, - // ...(config.IS_DEV && { - // logger: console, - // }), - }); - - return _proxyMiddleware; + return _proxyPromise; } const proxyMiddleware: RequestHandler = async (req, res, next) => { diff --git a/packages/app/pages/api/[...all].ts b/packages/app/pages/api/[...all].ts index bfc3bd590a..fdd5e486f5 100644 --- a/packages/app/pages/api/[...all].ts +++ b/packages/app/pages/api/[...all].ts @@ -1,5 +1,5 @@ import { NextApiRequest, NextApiResponse } from 'next'; -import { createProxyMiddleware } from 'http-proxy-middleware'; +import type { RequestHandler } from 'http-proxy-middleware'; const DEFAULT_SERVER_URL = `http://127.0.0.1:${process.env.HYPERDX_API_PORT}`; @@ -16,7 +16,26 @@ export const config = { // we proxy `/api/*` to a separately-deployed API service as before. const isInlineApi = process.env.HDX_PREVIEW_INLINE_API === 'true'; -export default (req: NextApiRequest, res: NextApiResponse) => { +// http-proxy-middleware v4 is ESM-only. Use a dynamic import with a cached +// promise so the module is loaded once and concurrent requests share the result. +let _proxyPromise: Promise | undefined; + +function getProxy(): Promise { + if (!_proxyPromise) { + _proxyPromise = import('http-proxy-middleware').then( + ({ createProxyMiddleware }) => + createProxyMiddleware({ + changeOrigin: true, + pathRewrite: { '^/api': '' }, + target: process.env.SERVER_URL || DEFAULT_SERVER_URL, + autoRewrite: true, + }), + ); + } + return _proxyPromise; +} + +export default async (req: NextApiRequest, res: NextApiResponse) => { if (isInlineApi) { // Lazy require so non-preview production builds — where the webpack // externals hook in next.config.mjs marks @hyperdx/api as external — @@ -27,16 +46,7 @@ export default (req: NextApiRequest, res: NextApiResponse) => { return handler(req, res); } - const proxy = createProxyMiddleware({ - changeOrigin: true, - // logger: console, // DEBUG - pathRewrite: { '^/api': '' }, - target: process.env.SERVER_URL || DEFAULT_SERVER_URL, - autoRewrite: true, - // ...(IS_DEV && { - // logger: console, - // }), - }); + const proxy = await getProxy(); return proxy(req, res, error => { if (error) { console.error(error);