feat: add variable interpolation support for WebSocket requests (#6064)

* feat: add variable interpolation support for WebSocket requests

- Add WebSocket body interpolation in interpolateVars function
- Interpolate URL, headers, and all messages in request.body.ws array with full variable context
- Refactor sendWsRequest to use main process preparation (removes duplication)
- Add mode property to wsRequest object for proper request type detection
- Ensure consistent variable precedence matching HTTP/gRPC requests
- Centralize all interpolation logic in main process via prepareWsRequest

* Add Playwright tests for WebSocket variable interpolation

- Add tests for URL interpolation (wss://echo.{{url}}.org)
- Add tests for message content interpolation ({"test": "{{data}}"})
- Update test fixtures to use wss://echo.websocket.org echo server
- Add WEBSOCKET_FLOWS.md documentation
- Refactor queueWsMessage to handle variable interpolation in main process

* removed ws flow documentation

* chore: updated the network/index.js file to reduce merge conflicts by moving around code

* fix: added collection and item to WsQueryUrl Editor to fix available variable highlight

* chore: remove unnecessary whitespace in WebSocket event handlers

---------

Co-authored-by: Sid <siddharth@usebruno.com>
This commit is contained in:
Chirag Chandrashekhar
2025-11-17 12:02:25 +05:30
committed by GitHub
parent 2d2a17c90f
commit 8ec1925b9f
9 changed files with 255 additions and 42 deletions

View File

@@ -104,6 +104,8 @@ const WsQueryUrl = ({ item, collection, handleRun }) => {
className="w-full"
theme={displayedTheme}
onRun={handleRun}
collection={collection}
item={item}
/>
<div className="flex items-center h-full mr-2 cursor-pointer">
<div

View File

@@ -241,25 +241,45 @@ export const connectWS = async (item, collection, environment, runtimeVariables,
});
};
export const sendWsRequest = (item, collection, environment, runtimeVariables) => {
return new Promise(async (resolve, reject) => {
const ensureConnection = async () => {
const connectionStatus = await isWsConnectionActive(item.uid);
if (!connectionStatus.isActive) {
await connectWS(item, collection, environment, runtimeVariables, { connectOnly: true });
}
};
const { request } = item.draft ? item.draft : item;
queueWsMessage(item, collection.uid, request.body.ws[0].content)
.then((initialState) => {
// Return an initial state object to update the UI
// The real response data will be handled by event listeners
resolve({
...initialState
});
})
.catch((err) => reject(err));
await ensureConnection();
export const sendWsRequest = async (item, collection, environment, runtimeVariables) => {
const ensureConnection = async () => {
const connectionStatus = await isWsConnectionActive(item.uid);
if (!connectionStatus.isActive) {
await connectWS(item, collection, environment, runtimeVariables, { connectOnly: true });
}
};
await ensureConnection();
// Use queueWsMessage helper to queue all messages with proper variable interpolation
const result = await queueWsMessage(item, collection, environment, runtimeVariables, null);
if (result.success) {
return {};
} else {
throw new Error(result.error || 'Failed to queue messages');
}
};
/**
* Queues a message to an existing WebSocket connection with variable interpolation
* @param {Object} item - The request item
* @param {Object} collection - The collection object
* @param {Object} environment - The environment variables
* @param {Object} runtimeVariables - The runtime variables
* @param {string} messageContent - The message content to queue (or null to queue all messages)
* @returns {Promise<Object>} - The result of the queue operation
*/
export const queueWsMessage = async (item, collection, environment, runtimeVariables, messageContent) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:ws:queue-message', {
item,
collection,
environment,
runtimeVariables,
messageContent
}).then(resolve).catch(reject);
});
};
@@ -287,20 +307,6 @@ export const startWsConnection = async (item, collection, environment, runtimeVa
});
};
/**
* Sends a message to an existing WebSocket connection
* @param {string} requestId - The request ID to send a message to
* @param {string} collectionUid - The collection ID the message is for
* @param {*} message - The message
* @returns {Promise<Object>} - The result of the send operation
*/
export const queueWsMessage = async (item, collectionUid, message) => {
return new Promise((resolve, reject) => {
const { ipcRenderer } = window;
ipcRenderer.invoke('renderer:ws:queue-message', item.uid, collectionUid, message).then(resolve).catch(reject);
});
};
/**
* Sends a message to an existing WebSocket connection
* @param {string} requestId - The request ID to send a message to

View File

@@ -81,6 +81,26 @@ const interpolateVars = (request, envVariables = {}, runtimeVariables = {}, proc
});
request.body = JSON.parse(parsed);
}
// Interpolate WebSocket message body
const isWsRequest = request.mode === 'ws';
if (isWsRequest && request.body && request.body.ws && Array.isArray(request.body.ws)) {
request.body.ws.forEach((message) => {
if (message && message.content) {
// Try to detect if content is JSON for proper escaping
let isJson = false;
try {
JSON.parse(message.content);
isJson = true;
} catch (e) {
// Not JSON, treat as regular string
}
message.content = _interpolate(message.content, {
escapeJSONStrings: isJson
});
}
});
}
if (typeof contentType === 'string') {
/*

View File

@@ -38,6 +38,8 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
mergeScripts(collection, request, requestTreePath, scriptFlow);
mergeVars(collection, request, requestTreePath);
mergeAuth(collection, request, requestTreePath);
request.globalEnvironmentVariables = collection?.globalEnvironmentVariables;
request.oauth2CredentialVariables = getFormattedCollectionOauth2Credentials({ oauth2Credentials: collection?.oauth2Credentials });
}
each(get(collectionRoot, 'request.headers', []), (h) => {
@@ -65,6 +67,7 @@ const prepareWsRequest = async (item, collection, environment, runtimeVariables,
let wsRequest = {
uid: item.uid,
mode: request.body.mode,
url: request.url,
headers,
processEnvVars,
@@ -276,15 +279,43 @@ const registerWsEventHandlers = (window) => {
}
});
ipcMain.handle('renderer:ws:queue-message', (event, requestId, collectionUid, message) => {
try {
wsClient.queueMessage(requestId, collectionUid, message);
return { success: true };
} catch (error) {
console.error('Error queuing WebSocket message:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('renderer:ws:queue-message',
async (event, { item, collection, environment, runtimeVariables, messageContent }) => {
try {
const itemCopy = cloneDeep(item);
const preparedRequest = await prepareWsRequest(itemCopy, collection, environment, runtimeVariables, {});
// If messageContent is provided, find and queue that specific message (interpolated)
// Otherwise, queue all messages
if (messageContent !== undefined && messageContent !== null) {
// Find the message index in the original request
const originalMessages = itemCopy.draft?.request?.body?.ws || itemCopy.request?.body?.ws || [];
const messageIndex = originalMessages.findIndex((msg) => msg.content === messageContent);
if (messageIndex >= 0 && preparedRequest.body?.ws?.[messageIndex]) {
// Queue the interpolated version of the specific message
wsClient.queueMessage(preparedRequest.uid, collection.uid, preparedRequest.body.ws[messageIndex].content);
} else {
// Message not found in request body, queue as-is (shouldn't happen in normal flow)
wsClient.queueMessage(preparedRequest.uid, collection.uid, messageContent);
}
} else {
// Queue all messages (they are already interpolated by prepareWsRequest -> interpolateVars)
if (preparedRequest.body && preparedRequest.body.ws && Array.isArray(preparedRequest.body.ws)) {
preparedRequest.body.ws
.filter((message) => message && message.content)
.forEach((message) => {
wsClient.queueMessage(preparedRequest.uid, collection.uid, message.content);
});
}
}
return { success: true };
} catch (error) {
console.error('Error queuing WebSocket message:', error);
return { success: false, error: error.message };
}
});
// Send a message to an existing WebSocket connection
ipcMain.handle('renderer:ws:send-message', (event, requestId, collectionUid, message) => {

View File

@@ -0,0 +1,10 @@
{
"version": "1",
"name": "variable-interpolation",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

@@ -0,0 +1,5 @@
vars {
url: websocket
data: test-data
}

View File

@@ -0,0 +1,20 @@
meta {
name: ws-interpolation-test
type: ws
seq: 1
}
ws {
url: wss://echo.{{url}}.org
auth: inherit
}
body:ws {
name: message 1
content: '''
{
"test": "{{data}}"
}
'''
}

View File

@@ -0,0 +1,10 @@
{
"maximized": false,
"lastOpenedCollections": [
"{{projectRoot}}/tests/websockets/variable-interpolation/fixtures/collection"
],
"beta": {
"websocket": true
}
}

View File

@@ -0,0 +1,109 @@
import { test, expect } from '../../../playwright';
import { buildWebsocketCommonLocators } from '../../utils/page/locators';
import { closeAllCollections, openCollectionAndAcceptSandbox } from '../../utils/page';
const BRU_REQ_NAME = /^ws-interpolation-test$/;
const MAX_CONNECTION_TIME = 10000; // Increased timeout for external server
test.describe.serial('WebSocket Variable Interpolation', () => {
test.afterAll(async ({ pageWithUserData: page }) => {
await closeAllCollections(page);
});
test('interpolates variables in WebSocket URL', async ({ pageWithUserData: page, restartApp }) => {
const locators = buildWebsocketCommonLocators(page);
// Open the collection and accept sandbox modal if it appears
await openCollectionAndAcceptSandbox(page, 'variable-interpolation', 'safe');
// Open the request
await expect(page.getByTitle(BRU_REQ_NAME)).toBeVisible();
await page.getByTitle(BRU_REQ_NAME).click();
// Select the test environment (which has url: websocket)
await page.locator('div.current-environment').click();
await expect(page.locator('.dropdown-item').filter({ hasText: 'Test' })).toBeVisible();
await page.locator('.dropdown-item').filter({ hasText: 'Test' }).click();
await expect(page.locator('.current-environment').filter({ hasText: /Test/ })).toBeVisible();
// Wait a bit for environment to be applied
await page.waitForTimeout(200);
// Connect WebSocket
await locators.connectionControls.connect().click();
// Wait for connection to establish
await expect(locators.connectionControls.disconnect()).toBeAttached({
timeout: MAX_CONNECTION_TIME
});
// Verify the connection message shows interpolated URL
// The URL should be wss://echo.websocket.org (not wss://echo.{{url}}.org)
await expect(locators.messages().first().getByText(/Connected to wss:\/\/echo\.websocket\.org/)).toBeAttached({
timeout: 2000
});
});
test('interpolates variables in WebSocket message content', async ({ pageWithUserData: page, restartApp }) => {
const locators = buildWebsocketCommonLocators(page);
// Wait for collection to be visible (it should auto-load from preferences)
await expect(page.locator('#sidebar-collection-name').filter({ hasText: 'variable-interpolation' })).toBeVisible({ timeout: 5000 });
// Check if sandbox modal is present and handle it
const sandboxModal = page.locator('.bruno-modal-card').filter({ has: page.locator('.bruno-modal-header-title', { hasText: 'JavaScript Sandbox' }) });
const isModalVisible = await sandboxModal.isVisible().catch(() => false);
if (isModalVisible) {
// Accept sandbox modal
await sandboxModal.getByLabel('Safe Mode').check();
await sandboxModal.locator('.bruno-modal-footer .submit').click();
await sandboxModal.waitFor({ state: 'detached' });
} else {
// Collection might already be open, just ensure it's clicked
await page.locator('#sidebar-collection-name').filter({ hasText: 'variable-interpolation' }).click();
}
// Wait a bit for any modals to fully close
await page.waitForTimeout(300);
// Open the request
await expect(page.getByTitle(BRU_REQ_NAME)).toBeVisible();
await page.getByTitle(BRU_REQ_NAME).click();
// Select the test environment (which has data: test-data)
await page.locator('div.current-environment').click();
await page.locator('.dropdown-item').filter({ hasText: 'Test' }).click();
// Clear any previous messages
await locators.toolbar.clearResponse().click();
// Send the request (connect + send messages)
await locators.runner().click();
// Wait for connection
await expect(locators.connectionControls.disconnect()).toBeAttached({
timeout: MAX_CONNECTION_TIME
});
// Wait a bit for messages to be sent and received (echo server echoes back)
await page.waitForTimeout(1000);
// Verify the sent message contains interpolated value
// Should send {"test": "test-data"} (not {"test": "{{data}}"})
const messages = locators.messages();
// Find the outgoing message with interpolated content
// The echo server will echo back the same message, so we should see it twice
const sentMessage = messages.filter({ hasText: 'test-data' }).first();
await expect(sentMessage).toBeAttached({ timeout: 2000 });
// Verify the message content shows interpolated value, not literal variable
const messageText = await sentMessage.locator('.text-ellipsis').textContent();
expect(messageText).toContain('test-data');
expect(messageText).not.toContain('{{data}}');
// Verify JSON structure is correct
expect(messageText).toMatch(/\{[\s\S]*"test"[\s\S]*"test-data"[\s\S]*\}/);
});
});