+ {effectiveRequest.proxy && effectiveRequest.proxy.mode !== 'off' && (
+
+ )}
+
{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);
+ });
+ });
});