diff --git a/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js index 014b7e131..aabc5d772 100644 --- a/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js +++ b/packages/bruno-app/src/components/ResponsePane/Timeline/GrpcTimelineItem/index.js @@ -10,7 +10,8 @@ import { IconCircleCheck, IconCircleX, IconX, - IconSend + IconSend, + IconArrowsRightLeft } from '@tabler/icons'; import StyledWrapper from './StyledWrapper'; import { usePersistedState } from 'hooks/usePersistedState/index'; @@ -78,6 +79,28 @@ const GrpcTimelineItem = ({ timestamp, request, response, eventType, collection, case 'request': return (
+ {effectiveRequest.proxy && effectiveRequest.proxy.mode !== 'off' && ( +
+
+ + Proxy +
+
+ {effectiveRequest.proxy.url ? ( +
+ Using {effectiveRequest.proxy.mode === 'system' ? 'system ' : ''}proxy: {effectiveRequest.proxy.url} +
+ ) : ( +
+ {effectiveRequest.proxy.mode === 'system' + ? 'No system proxy configured for this request' + : 'Proxy enabled but not applicable for this request'} +
+ )} +
+
+ )} + {effectiveRequest.headers && Object.keys(effectiveRequest.headers).length > 0 && (
Metadata
diff --git a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js index 32e208aad..05f084417 100644 --- a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js +++ b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.js @@ -10,10 +10,80 @@ const path = require('node:path'); const prepareGrpcRequest = require('./prepare-grpc-request'); const { normalizeAndResolvePath } = require('../../utils/filesystem'); const { configureRequest } = require('./prepare-grpc-request'); +const { shouldUseProxy } = require('../../utils/proxy-util'); // Creating grpcClient at module level so it can be accessed from window-all-closed event let grpcClient; +/** + * Resolve proxy configuration for gRPC requests. + * @grpc/grpc-js only supports HTTP protocol proxies (HTTP CONNECT tunneling). + * SOCKS and HTTPS proxy protocols are not supported. + * + * Always returns an object so that @grpc/grpc-js's built-in env var proxy + * (http_proxy/https_proxy) is disabled — Bruno controls all proxy behavior. + * + * @param {string} proxyMode - 'on', 'system', or 'off' + * @param {Object} proxyConfig - Raw proxy config from getCertsAndProxyConfig + * @param {string} requestUrl - The gRPC request URL + * @param {Object} interpolationOptions - Variable interpolation options + * @returns {{ proxyUrl: string | null }} + */ +const resolveGrpcProxyConfig = (proxyMode, proxyConfig, requestUrl, interpolationOptions) => { + if (proxyMode === 'on') { + const shouldProxy = shouldUseProxy(requestUrl, get(proxyConfig, 'bypassProxy', '')); + if (!shouldProxy) return { proxyUrl: null }; + + const protocol = interpolateString(get(proxyConfig, 'protocol', ''), interpolationOptions) || ''; + if (protocol.includes('socks') || protocol === 'https') { + console.warn(`gRPC proxy: "${protocol}" protocol not supported. Only HTTP proxies are supported for gRPC connections.`); + return { proxyUrl: null }; + } + + const hostname = interpolateString(get(proxyConfig, 'hostname'), interpolationOptions); + const port = interpolateString(String(get(proxyConfig, 'port', '')), interpolationOptions); + const authEnabled = !get(proxyConfig, 'auth.disabled', false); + const portStr = port ? `:${port}` : ''; + + if (authEnabled) { + const username = encodeURIComponent( + interpolateString(get(proxyConfig, 'auth.username'), interpolationOptions) + ); + const password = encodeURIComponent( + interpolateString(get(proxyConfig, 'auth.password'), interpolationOptions) + ); + return { proxyUrl: `http://${username}:${password}@${hostname}${portStr}` }; + } + return { proxyUrl: `http://${hostname}${portStr}` }; + } + + if (proxyMode === 'system') { + const { http_proxy, https_proxy, no_proxy } = proxyConfig || {}; + const shouldProxy = shouldUseProxy(requestUrl, no_proxy || ''); + if (!shouldProxy) return { proxyUrl: null }; + + const systemProxy = https_proxy || http_proxy; + if (!systemProxy) return { proxyUrl: null }; + + try { + const parsed = new URL(systemProxy); + if (parsed.protocol !== 'http:') { + console.warn( + `gRPC proxy: "${parsed.protocol}" system proxy protocol not supported. Only HTTP proxies are supported for gRPC connections.` + ); + return { proxyUrl: null }; + } + return { proxyUrl: systemProxy }; + } catch (e) { + console.warn('Invalid system proxy URL for gRPC:', systemProxy); + return { proxyUrl: null }; + } + } + + // proxyMode is 'off' — no proxy, but still disable env var proxy + return { proxyUrl: null }; +}; + /** * Extract protobuf include directories from collection config * @param {Object} collection - The collection object @@ -84,8 +154,8 @@ const registerGrpcEventHandlers = (window) => { certsAndProxyConfig ); - // Extract certificate information from the config - const { httpsAgentRequestFields } = certsAndProxyConfig; + // Extract certificate and proxy information from the config + const { httpsAgentRequestFields, proxyMode, proxyConfig, interpolationOptions } = certsAndProxyConfig; // Configure verify options const verifyOptions = { @@ -99,6 +169,9 @@ const registerGrpcEventHandlers = (window) => { const passphrase = httpsAgentRequestFields.passphrase; const pfx = httpsAgentRequestFields.pfx; + // Resolve proxy configuration for gRPC + const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions); + const requestSent = { type: 'request', url: preparedRequest.url, @@ -106,12 +179,16 @@ const registerGrpcEventHandlers = (window) => { methodType: preparedRequest.methodType, headers: preparedRequest.headers, body: preparedRequest.body, - timestamp: Date.now() + timestamp: Date.now(), + proxy: { + mode: proxyMode, + url: grpcProxyConfig.proxyUrl || null + } }; const includeDirs = getProtobufIncludeDirs(collection); - // Start gRPC connection with the processed request and certificates + // Start gRPC connection with the processed request, certificates, and proxy await grpcClient.startConnection({ request: preparedRequest, collection, @@ -121,7 +198,8 @@ const registerGrpcEventHandlers = (window) => { passphrase, pfx, verifyOptions, - includeDirs + includeDirs, + proxyConfig: grpcProxyConfig }); sendEvent('grpc:request', preparedRequest.uid, collection.uid, requestSent); @@ -236,8 +314,8 @@ const registerGrpcEventHandlers = (window) => { certsAndProxyConfig ); - // Extract certificate information from the config - const { httpsAgentRequestFields } = certsAndProxyConfig; + // Extract certificate and proxy information from the config + const { httpsAgentRequestFields, proxyMode, proxyConfig, interpolationOptions } = certsAndProxyConfig; // Configure verify options const verifyOptions = { @@ -251,6 +329,9 @@ const registerGrpcEventHandlers = (window) => { const passphrase = httpsAgentRequestFields.passphrase; const pfx = httpsAgentRequestFields.pfx; + // Resolve proxy configuration for gRPC + const grpcProxyConfig = resolveGrpcProxyConfig(proxyMode, proxyConfig, preparedRequest.url, interpolationOptions); + // Send OAuth credentials update if available if (preparedRequest?.oauth2Credentials) { window.webContents.send('main:credentials-update', { @@ -272,7 +353,8 @@ const registerGrpcEventHandlers = (window) => { passphrase, pfx, verifyOptions, - sendEvent + sendEvent, + proxyConfig: grpcProxyConfig }); return { success: true, methods: safeParseJSON(safeStringifyJSON(methods)) }; @@ -399,3 +481,4 @@ if (app && typeof app.on === 'function') { } module.exports = registerGrpcEventHandlers; +module.exports.resolveGrpcProxyConfig = resolveGrpcProxyConfig; diff --git a/packages/bruno-electron/src/ipc/network/grpc-event-handlers.spec.js b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.spec.js new file mode 100644 index 000000000..e548e4c39 --- /dev/null +++ b/packages/bruno-electron/src/ipc/network/grpc-event-handlers.spec.js @@ -0,0 +1,146 @@ +jest.mock('./interpolate-string', () => ({ + interpolateString: (str) => str +})); + +const { resolveGrpcProxyConfig } = require('./grpc-event-handlers'); + +const emptyInterpolationOptions = {}; + +describe('resolveGrpcProxyConfig', () => { + describe('proxyMode "off"', () => { + it('should return null proxyUrl', () => { + expect(resolveGrpcProxyConfig('off', {}, 'grpc://localhost:50051', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + }); + + describe('proxyMode "on"', () => { + it('should return proxy URL without auth', () => { + const proxyConfig = { + protocol: 'http', + hostname: 'proxy.example.com', + port: '8080', + auth: { disabled: true } + }; + expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: 'http://proxy.example.com:8080' }); + }); + + it('should return proxy URL with auth when auth is enabled', () => { + const proxyConfig = { + protocol: 'http', + hostname: 'proxy.example.com', + port: '8080', + auth: { disabled: false, username: 'user', password: 'pass' } + }; + expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: 'http://user:pass@proxy.example.com:8080' }); + }); + + it('should URL-encode special characters in credentials', () => { + const proxyConfig = { + protocol: 'http', + hostname: 'proxy.example.com', + port: '8080', + auth: { disabled: false, username: 'user@domain', password: 'p@ss:word' } + }; + const result = resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions); + expect(result.proxyUrl).toBe('http://user%40domain:p%40ss%3Aword@proxy.example.com:8080'); + }); + + it('should reject SOCKS proxy protocols', () => { + const proxyConfig = { + protocol: 'socks5', + hostname: 'proxy.example.com', + port: '1080' + }; + expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + + it('should reject HTTPS proxy protocol', () => { + const proxyConfig = { + protocol: 'https', + hostname: 'proxy.example.com', + port: '8080' + }; + expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + + it('should return null when request URL is in bypassProxy list', () => { + const proxyConfig = { + protocol: 'http', + hostname: 'proxy.example.com', + port: '8080', + bypassProxy: 'localhost,api.example.com' + }; + expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + + it('should omit port when not provided', () => { + const proxyConfig = { + protocol: 'http', + hostname: 'proxy.example.com', + auth: { disabled: true } + }; + expect(resolveGrpcProxyConfig('on', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: 'http://proxy.example.com' }); + }); + }); + + describe('proxyMode "system"', () => { + it('should use https_proxy when available', () => { + const proxyConfig = { + https_proxy: 'http://system-proxy.example.com:3128', + http_proxy: 'http://fallback-proxy.example.com:3128' + }; + expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: 'http://system-proxy.example.com:3128' }); + }); + + it('should fall back to http_proxy when https_proxy is not set', () => { + const proxyConfig = { + http_proxy: 'http://fallback-proxy.example.com:3128' + }; + expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: 'http://fallback-proxy.example.com:3128' }); + }); + + it('should return null when no system proxy is configured', () => { + expect(resolveGrpcProxyConfig('system', {}, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + + it('should reject non-HTTP system proxy protocols', () => { + const proxyConfig = { + https_proxy: 'socks5://system-proxy.example.com:1080' + }; + expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + + it('should return null when request URL matches no_proxy', () => { + const proxyConfig = { + https_proxy: 'http://system-proxy.example.com:3128', + no_proxy: 'api.example.com' + }; + expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + + it('should return null for invalid system proxy URL', () => { + const proxyConfig = { + https_proxy: 'not-a-valid-url' + }; + expect(resolveGrpcProxyConfig('system', proxyConfig, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + + it('should return null when proxyConfig is null', () => { + expect(resolveGrpcProxyConfig('system', null, 'grpc://api.example.com:443', emptyInterpolationOptions)) + .toEqual({ proxyUrl: null }); + }); + }); +}); diff --git a/packages/bruno-requests/src/grpc/grpc-client.js b/packages/bruno-requests/src/grpc/grpc-client.js index 7c91c32da..7d2b7b5fd 100644 --- a/packages/bruno-requests/src/grpc/grpc-client.js +++ b/packages/bruno-requests/src/grpc/grpc-client.js @@ -150,13 +150,21 @@ const getParsedGrpcUrlObject = (url) => { * @param {string} collectionUid - The collection UID * @param {Object} rpc - The gRPC object */ -const setupGrpcEventHandlers = (callback, requestId, collectionUid, rpc) => { +const setupGrpcEventHandlers = (callback, requestId, collectionUid, rpc, onComplete) => { + let completed = false; + const complete = () => { + if (completed) return; + completed = true; + if (typeof onComplete === 'function') onComplete(); + }; + rpc.on('status', (status, res) => { const statusWithMetadata = { ...status, metadata: processGrpcMetadata(status.metadata.getMap ? status.metadata.getMap() : status.metadata) }; callback('grpc:status', requestId, collectionUid, { status: statusWithMetadata, res }); + complete(); }); rpc.on('error', (error) => { @@ -165,12 +173,12 @@ const setupGrpcEventHandlers = (callback, requestId, collectionUid, rpc) => { metadata: processGrpcMetadata(error.metadata.getMap ? error.metadata.getMap() : error.metadata) }; callback('grpc:error', requestId, collectionUid, { error: errorWithMetadata }); + complete(); }); rpc.on('end', (res) => { callback('grpc:server-end-stream', requestId, collectionUid, { res }); - const channel = rpc?.call?.channel; - if (channel) channel.close(); + complete(); }); rpc.on('data', (res) => { @@ -179,9 +187,7 @@ const setupGrpcEventHandlers = (callback, requestId, collectionUid, rpc) => { rpc.on('cancel', (res) => { callback('grpc:server-cancel-stream', requestId, collectionUid, { res }); - - const channel = rpc?.call?.channel; - if (channel) channel.close(); + complete(); }); rpc.on('metadata', (metadata) => { @@ -234,6 +240,8 @@ class GrpcClient { services = await client.listServices('*', callOptions); return { client, services, callOptions }; } catch (e) { + // Close the failed v1 client's channel to prevent subchannel leaks + this.#closeReflectionClient(client); console.warn(`gRPC reflection v1 failed:`, e); } @@ -242,6 +250,21 @@ class GrpcClient { return { client, services, callOptions }; } + /** + * Close a GrpcReflection client's underlying gRPC channel. + * GrpcReflection doesn't expose a close() method, so we access the + * internal gRPC service client directly. + */ + #closeReflectionClient(reflectionClient) { + try { + if (reflectionClient?.client && typeof reflectionClient.client.close === 'function') { + reflectionClient.client.close(); + } + } catch (e) { + // Ignore close errors + } + } + /** * Get method type based on streaming configuration */ @@ -324,6 +347,66 @@ class GrpcClient { } } + /** + * Resolve proxy target and channel options for HTTP CONNECT proxying. + * @grpc/grpc-js supports HTTP proxies via channel options. + * SOCKS and HTTPS proxy protocols are not supported. + * + * When proxyConfig is an object (even with null proxyUrl), @grpc/grpc-js's + * built-in env var proxy (http_proxy/https_proxy) is disabled + * so that Bruno has full control over proxy behavior. + * + * @param {string} originalHost - The original gRPC server host:port + * @param {Object} [proxyConfig] - Proxy configuration. Pass undefined to leave env var proxy enabled. + * @param {string|null} proxyConfig.proxyUrl - The HTTP proxy URL, or null for no proxy + * @returns {{ targetHost: string, proxyChannelOptions: Object }} + */ + #resolveProxyTarget(originalHost, proxyConfig) { + // When proxyConfig is not provided (undefined), don't touch env var proxy + if (proxyConfig === undefined || proxyConfig === null) { + return { targetHost: originalHost, proxyChannelOptions: {} }; + } + + // Bruno is managing proxy — disable @grpc/grpc-js built-in env var proxy + // so that http_proxy/https_proxy env vars don't interfere. + // Use a local subchannel pool so that old proxy subchannels don't persist + // in the global pool and retry against stale proxy addresses. + const baseOptions = { + 'grpc.enable_http_proxy': 0, + 'grpc.use_local_subchannel_pool': 1 + }; + + if (!proxyConfig.proxyUrl) { + return { targetHost: originalHost, proxyChannelOptions: baseOptions }; + } + + // Don't proxy local transports + if (isUnixSocket(originalHost) || isWindowsNamedPipe(originalHost)) { + return { targetHost: originalHost, proxyChannelOptions: baseOptions }; + } + + // We replicate what @grpc/grpc-js's internal mapProxyName() does: + // redirect the channel target to the proxy and set http_connect_target/creds + // so that getProxiedConnection() performs the HTTP CONNECT handshake. + // These channel options are marked "internal" in channel-options.ts, but + // there is no public programmatic proxy API — the only alternative is + // setting process-wide env vars (http_proxy), which is racy. + const proxyUrl = new URL(proxyConfig.proxyUrl); + const proxyChannelOptions = { + ...baseOptions, + 'grpc.http_connect_target': `dns:${originalHost}`, + 'grpc.default_authority': originalHost + }; + + if (proxyUrl.username) { + proxyChannelOptions['grpc.http_connect_creds'] + = `${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`; + } + + const targetHost = `${proxyUrl.hostname}:${proxyUrl.port || 80}`; + return { targetHost, proxyChannelOptions }; + } + /** * Get method from the path */ @@ -348,7 +431,7 @@ class GrpcClient { * @returns {Promise} Whether methods were successfully refreshed * @private */ - async #refreshMethods({ url, headers, protoPath, collectionPath, collectionUid, certificates = {}, verifyOptions, includeDirs = [] }) { + async #refreshMethods({ url, headers, protoPath, collectionPath, collectionUid, certificates = {}, verifyOptions, includeDirs = [], proxyConfig }) { try { // Try reflection first if no proto path is specified if (!protoPath) { @@ -361,7 +444,8 @@ class GrpcClient { passphrase: certificates.passphrase, pfx: certificates.pfx, verifyOptions, - sendEvent: () => {} // No-op for refresh + sendEvent: () => {}, // No-op for refresh + proxyConfig }); return true; } @@ -425,8 +509,9 @@ class GrpcClient { this.eventCallback('grpc:response', requestId, collectionUid, { error, res }); } ); + this.#addConnection(requestId, { rpc, client }); - setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc); + setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc, () => this.#removeConnection(requestId)); } #handleClientStreamingResponse({ client, requestId, requestPath, method, metadata, collectionUid }) { @@ -439,9 +524,9 @@ class GrpcClient { this.eventCallback('grpc:response', requestId, collectionUid, { error, res }); } ); - this.#addConnection(requestId, rpc); + this.#addConnection(requestId, { rpc, client }); - setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc); + setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc, () => this.#removeConnection(requestId)); } #handleServerStreamingResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid }) { @@ -456,9 +541,9 @@ class GrpcClient { this.eventCallback('grpc:response', requestId, collectionUid, { error, res }); } ); - this.#addConnection(requestId, rpc); + this.#addConnection(requestId, { rpc, client }); - setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc); + setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc, () => this.#removeConnection(requestId)); } #handleBidiStreamingResponse({ client, requestId, requestPath, method, messages, metadata, collectionUid }) { @@ -468,9 +553,9 @@ class GrpcClient { method.responseDeserialize, metadata ); - this.#addConnection(requestId, rpc); + this.#addConnection(requestId, { rpc, client }); - setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc); + setupGrpcEventHandlers(this.eventCallback, requestId, collectionUid, rpc, () => this.#removeConnection(requestId)); } /** @@ -493,6 +578,8 @@ class GrpcClient { * @param {Object} [params.verifyOptions] - Additional options for verifying the server certificate * @param {import('@grpc/grpc-js').ChannelOptions} [params.channelOptions] - Additional options for the gRPC channel * @param {string[]} [params.includeDirs] - Include directories for proto file resolution + * @param {Object} [params.proxyConfig] - HTTP proxy configuration + * @param {string} params.proxyConfig.proxyUrl - The HTTP proxy URL */ async startConnection({ request, @@ -504,7 +591,8 @@ class GrpcClient { pfx, verifyOptions, channelOptions = {}, - includeDirs = [] + includeDirs = [], + proxyConfig }) { const credentials = this.#getChannelCredentials({ url: request.url, @@ -541,7 +629,8 @@ class GrpcClient { pfx }, verifyOptions, - includeDirs + includeDirs, + proxyConfig }); if (!refreshSuccess) { @@ -563,12 +652,16 @@ class GrpcClient { ); const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null; - const mergedChannelOptions = userAgentValue - ? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions } - : channelOptions; + // Resolve proxy target and channel options + const { targetHost, proxyChannelOptions } = this.#resolveProxyTarget(host, proxyConfig); + + const mergedChannelOptions = { ...channelOptions, ...proxyChannelOptions }; + if (userAgentValue && !channelOptions?.['grpc.primary_user_agent']) { + mergedChannelOptions['grpc.primary_user_agent'] = userAgentValue; + } const Client = makeGenericClientConstructor({}); - const client = new Client(host, credentials, mergedChannelOptions); + const client = new Client(targetHost, credentials, mergedChannelOptions); if (!client) { throw new Error('Failed to create client'); } @@ -578,6 +671,7 @@ class GrpcClient { messages = messages.map(({ content }) => safeJsonParse(content, 'message content')); } catch (parseError) { console.error('Failed to parse gRPC message content:', parseError); + client.close(); this.eventCallback('grpc:error', request.uid, collection.uid, { error: parseError }); @@ -610,9 +704,10 @@ class GrpcClient { * @param {Object|string} body - The message body to send, can be a JSON object or a string */ sendMessage(requestId, collectionUid, body) { - const connection = this.activeConnections.get(requestId); + const entry = this.activeConnections.get(requestId); + const rpc = entry?.rpc; - if (connection) { + if (rpc) { let parsedBody; // Parse the body if it's a string, with error handling @@ -631,7 +726,7 @@ class GrpcClient { parsedBody = body; } - connection.write(parsedBody, (error) => { + rpc.write(parsedBody, (error) => { if (error) { this.eventCallback('grpc:error', requestId, collectionUid, { error }); } @@ -652,7 +747,8 @@ class GrpcClient { pfx, verifyOptions, sendEvent, - channelOptions = {} + channelOptions = {}, + proxyConfig }) { const { host, path } = getParsedGrpcUrlObject(request.url); @@ -662,7 +758,14 @@ class GrpcClient { (key) => key.toLowerCase() === 'user-agent' ); const userAgentValue = userAgentKey ? request.headers[userAgentKey] : null; - const mergedChannelOptions = userAgentValue ? { 'grpc.primary_user_agent': userAgentValue, ...channelOptions } : channelOptions; + + // Resolve proxy target and channel options + const { targetHost, proxyChannelOptions } = this.#resolveProxyTarget(host, proxyConfig); + + const mergedChannelOptions = { ...channelOptions, ...proxyChannelOptions }; + if (userAgentValue && !channelOptions?.['grpc.primary_user_agent']) { + mergedChannelOptions['grpc.primary_user_agent'] = userAgentValue; + } const metadata = new Metadata(); Object.entries(request.headers).forEach(([name, value]) => { @@ -678,8 +781,10 @@ class GrpcClient { verifyOptions }); + let reflectionClient = null; try { - const { client, services, callOptions } = await this.#getReflectionClient(host, credentials, metadata, mergedChannelOptions); + const { client, services, callOptions } = await this.#getReflectionClient(targetHost, credentials, metadata, mergedChannelOptions); + reflectionClient = client; const methods = []; for (const service of services) { @@ -707,6 +812,8 @@ class GrpcClient { console.error('Error in gRPC reflection:', error); sendEvent('grpc:error', request.uid, collectionUid, { error }); throw error; + } finally { + this.#closeReflectionClient(reflectionClient); } } @@ -726,19 +833,34 @@ class GrpcClient { } end(requestId) { - const connection = this.activeConnections.get(requestId); - if (connection && typeof connection.end === 'function') { - connection.end(); - this.#removeConnection(requestId); + const entry = this.activeConnections.get(requestId); + if (!entry) return; + + // Only signal end of client messages — don't close the channel. + // The response stream may still be active. The channel is closed + // when the stream fully completes (via onComplete → #removeConnection). + if (entry.rpc && typeof entry.rpc.end === 'function') { + entry.rpc.end(); } } cancel(requestId) { - const connection = this.activeConnections.get(requestId); - if (connection && typeof connection.cancel === 'function') { - connection.cancel(); - this.#removeConnection(requestId); + this.#cancelAndCloseConnection(requestId); + } + + /** + * Cancel an existing connection's RPC and then close it. + * @param {string} requestId - The request ID + * @private + */ + #cancelAndCloseConnection(requestId) { + const entry = this.activeConnections.get(requestId); + if (!entry) return; + + if (entry.rpc && typeof entry.rpc.cancel === 'function') { + entry.rpc.cancel(); } + this.#removeConnection(requestId); } /** @@ -756,18 +878,14 @@ class GrpcClient { clearAllConnections() { const connectionIds = this.getActiveConnectionIds(); - this.activeConnections.forEach((connection) => { - if (typeof connection.cancel === 'function') { - connection.cancel(); - } - }); + for (const id of connectionIds) { + this.#cancelAndCloseConnection(id); + } - this.activeConnections.clear(); - - // Emit an event with empty active connection IDs + // Emit once if there were connections (individual calls already emitted, + // but ensure clean state) if (connectionIds.length > 0) { this.eventCallback('grpc:connections-changed', { - type: 'cleared', activeConnectionIds: [] }); } @@ -826,16 +944,18 @@ class GrpcClient { /** * Add a connection to the active connections map and emit an event * @param {string} requestId - The request ID - * @param {Object} connection - The connection object + * @param {Object} rpc - The gRPC call object + * @param {Object} client - The gRPC client instance (used to close the channel) * @private */ - #addConnection(requestId, connection) { - this.activeConnections.set(requestId, connection); + #addConnection(requestId, { rpc, client }) { + // Cancel and close any existing connection to prevent channel leaks + this.#cancelAndCloseConnection(requestId); + + this.activeConnections.set(requestId, { rpc, client }); // Emit an event with all active connection IDs this.eventCallback('grpc:connections-changed', { - type: 'added', - requestId, activeConnectionIds: this.getActiveConnectionIds() }); } @@ -846,16 +966,19 @@ class GrpcClient { * @private */ #removeConnection(requestId) { - if (this.activeConnections.has(requestId)) { - this.activeConnections.delete(requestId); + const entry = this.activeConnections.get(requestId); + if (!entry) return; - // Emit an event with all active connection IDs - this.eventCallback('grpc:connections-changed', { - type: 'removed', - requestId, - activeConnectionIds: this.getActiveConnectionIds() - }); + // Close the client channel to destroy subchannels and stop reconnect timers + if (entry.client && typeof entry.client.close === 'function') { + entry.client.close(); } + this.activeConnections.delete(requestId); + + // Emit an event with all active connection IDs + this.eventCallback('grpc:connections-changed', { + activeConnectionIds: this.getActiveConnectionIds() + }); } /** diff --git a/packages/bruno-requests/src/grpc/grpc-client.spec.js b/packages/bruno-requests/src/grpc/grpc-client.spec.js index 72f601c61..74b6f5931 100644 --- a/packages/bruno-requests/src/grpc/grpc-client.spec.js +++ b/packages/bruno-requests/src/grpc/grpc-client.spec.js @@ -2,8 +2,9 @@ * @jest-environment node */ -// Store captured channel options for assertions +// Store captured values for assertions let capturedChannelOptions = null; +let capturedHost = null; // Mock GrpcReflection to capture options const mockListServices = jest.fn().mockResolvedValue(['test.Service']); @@ -20,6 +21,7 @@ const mockListMethods = jest.fn().mockResolvedValue([ jest.mock('grpc-js-reflection-client', () => ({ GrpcReflection: jest.fn().mockImplementation((host, credentials, options) => { capturedChannelOptions = options; + capturedHost = host; return { listServices: mockListServices, listMethods: mockListMethods @@ -67,6 +69,7 @@ jest.mock('@grpc/grpc-js', () => { makeGenericClientConstructor: jest.fn(() => { return jest.fn().mockImplementation((host, credentials, options) => { capturedChannelOptions = options; + capturedHost = host; const mockRpc = createMockRpc(); return { close: jest.fn(), @@ -105,6 +108,7 @@ describe('GrpcClient', () => { beforeEach(() => { jest.clearAllMocks(); capturedChannelOptions = null; + capturedHost = null; mockEventCallback = jest.fn(); grpcClient = new GrpcClient(mockEventCallback); }); @@ -505,4 +509,210 @@ describe('GrpcClient', () => { }); }); }); + + describe('Proxy support in startConnection', () => { + const baseRequest = { + url: 'grpc://myserver:50051', + uid: 'test-request-uid', + method: '/test.Service/TestMethod', + headers: {}, + body: { + grpc: [{ content: '{}' }] + } + }; + + const baseCollection = { + uid: 'test-collection-uid', + pathname: '/test/path' + }; + + beforeEach(() => { + grpcClient.methods.set('/test.Service/TestMethod', { + path: '/test.Service/TestMethod', + requestStream: false, + responseStream: false, + requestSerialize: (val) => Buffer.from(JSON.stringify(val)), + responseDeserialize: (val) => JSON.parse(val.toString()) + }); + }); + + test('should set proxy channel options when proxyConfig is provided', async () => { + await grpcClient.startConnection({ + request: baseRequest, + collection: baseCollection, + proxyConfig: { proxyUrl: 'http://proxy.example.com:8080' } + }); + + expect(capturedChannelOptions['grpc.http_connect_target']).toBe('dns:myserver:50051'); + expect(capturedChannelOptions['grpc.default_authority']).toBe('myserver:50051'); + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBe(0); + expect(capturedChannelOptions['grpc.use_local_subchannel_pool']).toBe(1); + expect(capturedHost).toBe('proxy.example.com:8080'); + }); + + test('should set proxy auth credentials when proxy has username/password', async () => { + await grpcClient.startConnection({ + request: baseRequest, + collection: baseCollection, + proxyConfig: { proxyUrl: 'http://user:p%40ss@proxy.example.com:8080' } + }); + + expect(capturedChannelOptions['grpc.http_connect_creds']).toBe('user:p@ss'); + expect(capturedChannelOptions['grpc.http_connect_target']).toBe('dns:myserver:50051'); + expect(capturedHost).toBe('proxy.example.com:8080'); + }); + + test('should not set proxy credentials when proxy has no auth', async () => { + await grpcClient.startConnection({ + request: baseRequest, + collection: baseCollection, + proxyConfig: { proxyUrl: 'http://proxy.example.com:3128' } + }); + + expect(capturedChannelOptions['grpc.http_connect_creds']).toBeUndefined(); + expect(capturedHost).toBe('proxy.example.com:3128'); + }); + + test('should disable env var proxy and use local pool when proxyUrl is null (Bruno proxy off)', async () => { + await grpcClient.startConnection({ + request: baseRequest, + collection: baseCollection, + proxyConfig: { proxyUrl: null } + }); + + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBe(0); + expect(capturedChannelOptions['grpc.use_local_subchannel_pool']).toBe(1); + expect(capturedChannelOptions['grpc.http_connect_target']).toBeUndefined(); + expect(capturedHost).toBe('myserver:50051'); + }); + + test('should not set proxy options when proxyConfig is null (unmanaged)', async () => { + await grpcClient.startConnection({ + request: baseRequest, + collection: baseCollection, + proxyConfig: null + }); + + expect(capturedChannelOptions['grpc.http_connect_target']).toBeUndefined(); + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBeUndefined(); + expect(capturedHost).toBe('myserver:50051'); + }); + + test('should not set proxy options when proxyConfig is undefined (unmanaged)', async () => { + await grpcClient.startConnection({ + request: baseRequest, + collection: baseCollection + }); + + expect(capturedChannelOptions['grpc.http_connect_target']).toBeUndefined(); + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBeUndefined(); + expect(capturedHost).toBe('myserver:50051'); + }); + + test('should default proxy port to 80 when not specified', async () => { + await grpcClient.startConnection({ + request: baseRequest, + collection: baseCollection, + proxyConfig: { proxyUrl: 'http://proxy.example.com' } + }); + + expect(capturedHost).toBe('proxy.example.com:80'); + }); + + test('should not proxy unix socket targets', async () => { + const unixRequest = { + ...baseRequest, + url: 'unix:/var/run/grpc.sock' + }; + + await grpcClient.startConnection({ + request: unixRequest, + collection: baseCollection, + proxyConfig: { proxyUrl: 'http://proxy.example.com:8080' } + }); + + expect(capturedChannelOptions['grpc.http_connect_target']).toBeUndefined(); + }); + + test('should merge proxy options with user-agent and channelOptions', async () => { + const request = { + ...baseRequest, + headers: { 'User-Agent': 'Bruno/1.0' } + }; + + await grpcClient.startConnection({ + request, + collection: baseCollection, + channelOptions: { 'grpc.max_receive_message_length': 1024 * 1024 }, + proxyConfig: { proxyUrl: 'http://proxy.example.com:8080' } + }); + + expect(capturedChannelOptions['grpc.primary_user_agent']).toBe('Bruno/1.0'); + expect(capturedChannelOptions['grpc.max_receive_message_length']).toBe(1024 * 1024); + expect(capturedChannelOptions['grpc.http_connect_target']).toBe('dns:myserver:50051'); + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBe(0); + expect(capturedHost).toBe('proxy.example.com:8080'); + }); + }); + + describe('Proxy support in loadMethodsFromReflection', () => { + const baseRequest = { + url: 'grpc://myserver:50051', + uid: 'test-request-uid', + headers: {} + }; + + const baseParams = { + collectionUid: 'test-collection-uid', + sendEvent: jest.fn() + }; + + test('should set proxy channel options for reflection client', async () => { + await grpcClient.loadMethodsFromReflection({ + request: baseRequest, + ...baseParams, + proxyConfig: { proxyUrl: 'http://proxy.example.com:8080' } + }); + + expect(capturedChannelOptions['grpc.http_connect_target']).toBe('dns:myserver:50051'); + expect(capturedChannelOptions['grpc.default_authority']).toBe('myserver:50051'); + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBe(0); + expect(capturedHost).toBe('proxy.example.com:8080'); + }); + + test('should set proxy auth credentials for reflection client', async () => { + await grpcClient.loadMethodsFromReflection({ + request: baseRequest, + ...baseParams, + proxyConfig: { proxyUrl: 'http://admin:secret@proxy.example.com:8080' } + }); + + expect(capturedChannelOptions['grpc.http_connect_creds']).toBe('admin:secret'); + expect(capturedHost).toBe('proxy.example.com:8080'); + }); + + test('should disable env var proxy for reflection when proxyUrl is null', async () => { + await grpcClient.loadMethodsFromReflection({ + request: baseRequest, + ...baseParams, + proxyConfig: { proxyUrl: null } + }); + + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBe(0); + expect(capturedChannelOptions['grpc.http_connect_target']).toBeUndefined(); + expect(capturedHost).toBe('myserver:50051'); + }); + + test('should not set proxy options for reflection when proxyConfig is null', async () => { + await grpcClient.loadMethodsFromReflection({ + request: baseRequest, + ...baseParams, + proxyConfig: null + }); + + expect(capturedChannelOptions['grpc.http_connect_target']).toBeUndefined(); + expect(capturedChannelOptions['grpc.enable_http_proxy']).toBeUndefined(); + expect(capturedHost).toBe('myserver:50051'); + }); + }); }); diff --git a/tests/grpc/make-request/make-request.spec.ts b/tests/grpc/make-request/make-request.spec.ts index c7da0a75f..4de657ee2 100644 --- a/tests/grpc/make-request/make-request.spec.ts +++ b/tests/grpc/make-request/make-request.spec.ts @@ -120,6 +120,7 @@ test.describe('make grpc requests', () => { await test.step('start client streaming connection', async () => { await locators.request.sendButton().click(); await expect(locators.request.endConnectionButton()).toBeVisible(); + await expect(locators.request.cancelConnectionButton()).toBeVisible(); }); await test.step('send individual message', async () => { @@ -134,6 +135,12 @@ test.describe('make grpc requests', () => { await expect(locators.response.statusText()).toHaveText(/OK/); }); + await test.step('verify connection is closed after end', async () => { + await expect(locators.request.endConnectionButton()).not.toBeVisible(); + await expect(locators.request.cancelConnectionButton()).not.toBeVisible(); + await expect(locators.request.sendButton()).toBeVisible(); + }); + await test.step('verify response message count', async () => { await expect(locators.response.tabCount()).toHaveText('1'); }); @@ -170,6 +177,7 @@ test.describe('make grpc requests', () => { await test.step('start bidirectional streaming connection', async () => { await locators.request.sendButton().click(); await expect(locators.request.endConnectionButton()).toBeVisible(); + await expect(locators.request.cancelConnectionButton()).toBeVisible(); }); await test.step('send individual message', async () => { @@ -185,6 +193,12 @@ test.describe('make grpc requests', () => { await expect(locators.response.statusText()).toHaveText(/OK/); }); + await test.step('verify connection is closed after end', async () => { + await expect(locators.request.endConnectionButton()).not.toBeVisible(); + await expect(locators.request.cancelConnectionButton()).not.toBeVisible(); + await expect(locators.request.sendButton()).toBeVisible(); + }); + await test.step('verify response message count', async () => { await expect(locators.response.tabCount()).toHaveText('2'); }); @@ -201,4 +215,84 @@ test.describe('make grpc requests', () => { await page.keyboard.press(saveShortcut); }); }); + + test('cancel client streaming request', async ({ pageWithUserData: page }) => { + await setupGrpcTest(page); + const locators = buildGrpcCommonLocators(page); + + await test.step('select client streaming method', async () => { + await locators.sidebar.request('LotOfGreetings').click(); + await expect(locators.method.dropdownTrigger()).toContainText('HelloService/LotsOfGreetings'); + }); + + await test.step('start client streaming connection', async () => { + await locators.request.sendButton().click(); + await expect(locators.request.endConnectionButton()).toBeVisible(); + await expect(locators.request.cancelConnectionButton()).toBeVisible(); + }); + + await test.step('cancel the connection', async () => { + await locators.request.cancelConnectionButton().click(); + }); + + await test.step('verify connection is cancelled', async () => { + await expect(locators.response.statusCode()).toBeVisible({ timeout: 5000 }); + await expect(locators.response.statusText()).toBeVisible(); + await expect(locators.response.statusCode()).toHaveText(/1/); + await expect(locators.response.statusText()).toHaveText(/CANCELLED/); + }); + + await test.step('verify connection controls are reset', async () => { + await expect(locators.request.endConnectionButton()).not.toBeVisible(); + await expect(locators.request.cancelConnectionButton()).not.toBeVisible(); + await expect(locators.request.sendButton()).toBeVisible(); + }); + + /* TODO: Reflection fetching incorrectly marks requests as modified, causing save indicators to appear. This save step prevents test timeouts by clearing the modified state. This is a temporary workaround until the reflection fetching issue is resolved. */ + await test.step('save request via shortcut', async () => { + await page.keyboard.press(saveShortcut); + }); + }); + + test('cancel bidi streaming request', async ({ pageWithUserData: page }) => { + await setupGrpcTest(page); + const locators = buildGrpcCommonLocators(page); + + await test.step('select bidirectional streaming method', async () => { + await locators.sidebar.request('BidiHello').click(); + await expect(locators.method.dropdownTrigger()).toContainText('HelloService/BidiHello'); + }); + + await test.step('start bidirectional streaming connection', async () => { + await locators.request.sendButton().click(); + await expect(locators.request.endConnectionButton()).toBeVisible(); + await expect(locators.request.cancelConnectionButton()).toBeVisible(); + }); + + await test.step('send a message before cancelling', async () => { + await locators.request.sendMessage(0).click(); + }); + + await test.step('cancel the connection', async () => { + await locators.request.cancelConnectionButton().click(); + }); + + await test.step('verify connection is cancelled', async () => { + await expect(locators.response.statusCode()).toBeVisible({ timeout: 5000 }); + await expect(locators.response.statusText()).toBeVisible(); + await expect(locators.response.statusCode()).toHaveText(/1/); + await expect(locators.response.statusText()).toHaveText(/CANCELLED/); + }); + + await test.step('verify connection controls are reset', async () => { + await expect(locators.request.endConnectionButton()).not.toBeVisible(); + await expect(locators.request.cancelConnectionButton()).not.toBeVisible(); + await expect(locators.request.sendButton()).toBeVisible(); + }); + + /* TODO: Reflection fetching incorrectly marks requests as modified, causing save indicators to appear. This save step prevents test timeouts by clearing the modified state. This is a temporary workaround until the reflection fetching issue is resolved. */ + await test.step('save request via shortcut', async () => { + await page.keyboard.press(saveShortcut); + }); + }); });