feat: add gRPC proxy support (#7575)

* feat: add gRPC proxy support

* fix: respect channelOptions grpc.primary_user_agent over User-Agent header

* fix: remove non-standard grpc_proxy/no_grpc_proxy env var support

These are not recognized by any standard gRPC implementation. gRPC proxy
now uses the standard http_proxy/https_proxy/no_proxy variables like
grpc-core, grpc-go, and grpc-java.

* chore: add resolveGrpcProxyConfig tests and clean up grpc-client

Export resolveGrpcProxyConfig for testability and add unit tests covering
all proxy modes (off, on, system), auth encoding, protocol rejection,
bypass lists, and edge cases. Remove redundant cancelAndCloseConnection
call in startConnection (already guarded by addConnection). Document why
internal @grpc/grpc-js channel options are used for programmatic proxy.
This commit is contained in:
lohit
2026-04-01 16:09:34 +00:00
committed by GitHub
parent d73e01993d
commit 0a9988f80d
6 changed files with 746 additions and 67 deletions

View File

@@ -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 (
<div className="content-request">
{effectiveRequest.proxy && effectiveRequest.proxy.mode !== 'off' && (
<div>
<div className="content-request-label mb-1">
<IconArrowsRightLeft size={14} strokeWidth={1.5} className="inline-block mr-1" />
Proxy
</div>
<div className="content-box">
{effectiveRequest.proxy.url ? (
<div>
Using {effectiveRequest.proxy.mode === 'system' ? 'system ' : ''}proxy: {effectiveRequest.proxy.url}
</div>
) : (
<div className="empty-text">
{effectiveRequest.proxy.mode === 'system'
? 'No system proxy configured for this request'
: 'Proxy enabled but not applicable for this request'}
</div>
)}
</div>
</div>
)}
{effectiveRequest.headers && Object.keys(effectiveRequest.headers).length > 0 && (
<div>
<div className="content-request-label mb-1">Metadata</div>

View File

@@ -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;

View File

@@ -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 });
});
});
});

View File

@@ -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<boolean>} 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,17 +966,20 @@ class GrpcClient {
* @private
*/
#removeConnection(requestId) {
if (this.activeConnections.has(requestId)) {
const entry = this.activeConnections.get(requestId);
if (!entry) return;
// 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', {
type: 'removed',
requestId,
activeConnectionIds: this.getActiveConnectionIds()
});
}
}
/**
* Generate a grpcurl command for a gRPC request

View File

@@ -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');
});
});
});

View File

@@ -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);
});
});
});